player/src/KubikonPlayer/KubikonChatPanel.jsx
МИН 87444ee2c8 Initial public release: Rublox Player v1.0
Open-source web player for Rublox games, dual-licensed under
AGPL-3.0 + Commercial.

Highlights:
- Babylon.js 7 + React 18 + Vite 5 stack
- Self-contained engine (~46k lines): BlockManager, ModelManager,
  PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD
  gamemodes
- Configurable backend via VITE_API_BASE and friends — works against
  staging (dev-api.rublox.pro) out of the box
- Standalone mode (VITE_STANDALONE=true) loads a bundled sample game
  for first-run without any backend
- Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG
- Lint + format scaffolding (ESLint + Prettier + EditorConfig)
- Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md
- Issue templates: bug_report, feature_request, security_disclosure

Removed before public release:
- frontend_deploy.py (contained production SSH credentials)
- ~27 admin endpoints (kept in private repo)
- Hard-coded internal URLs and IPs
- All previous git history (clean repo init)
2026-05-27 23:04:04 +03:00

871 lines
37 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { jwtDecode } from 'jwt-decode';
import { io } from 'socket.io-client';
import * as Kubikon3DApi from '../api/Kubikon3DService';
import { STORYS_addres } from '../api/API';
import { formatTimeShort } from '../utils/kubikonTime';
import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice';
/**
* KubikonChatPanel — внутриигровой WebSocket-чат для опубликованной игры.
*
* Транспорт: Socket.IO, namespace /kubikon3d/chat, комната project_<id>.
* Если WS не установился (proxy режется, brownfield-сеть) — падаем на REST polling.
*
* События WS:
* ← chat:hello { last_id, online, messages, is_guest }
* ← chat:message { id, project_id, user_id, username, text, created_at, ... }
* ← chat:online { count }
* ← chat:muted { banned_until, reason, is_manual }
* ← chat:error { message, reason? }
*
* Props: projectId, onClose, onRequestAuth(action)
*/
const POLL_INTERVAL_MS = 3000;
const WS_FAILOVER_AFTER_MS = 4000; // если за 4с не подсоединились — переходим на polling
/**
* Если передан `realtimeRoom` (Colyseus.js Room из мультиплеера) — чат идёт
* через него, без обращения к storys-микросервису. Это режим in-game чата:
* • видны только сообщения игроков ИЗ ЭТОЙ КОМНАТЫ (не глобальный чат игры)
* • без истории — новые подключившиеся видят только сообщения с момента
* подключения
* • без мьютов / модерации (комната живёт минуты, пока есть игроки)
*
* Если `realtimeRoom` НЕ передан — старая логика (Socket.IO к storys).
*/
const KubikonChatPanel = ({ projectId, onClose, onRequestAuth, compact = false, realtimeRoom = null, mobileMode = false }) => {
const [messages, setMessages] = useState([]);
const [text, setText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [muteInfo, setMuteInfo] = useState(null); // активный мьют
const [loaded, setLoaded] = useState(false);
const [online, setOnline] = useState(0);
const [transport, setTransport] = useState('connecting'); // 'ws' | 'polling' | 'connecting' | 'realtime'
const [emailNotice, setEmailNotice] = useState(false); // окно «подтвердите email»
const lastIdRef = useRef(0);
const listRef = useRef(null);
const socketRef = useRef(null);
// onRequestAuth от родителя — стабилизируем через ref, чтобы он не попадал
// в deps useEffect и не вызывал re-mount (=пересоздание WS) каждый рендер.
const onRequestAuthRef = useRef(onRequestAuth);
useEffect(() => { onRequestAuthRef.current = onRequestAuth; }, [onRequestAuth]);
const userInfo = (() => {
try {
const raw = localStorage.getItem('Authorization');
if (!raw) return null;
const token = raw.startsWith('Bearer ') ? raw.slice(7).trim() : raw.trim();
if (!token) return null;
const p = jwtDecode(token);
// Гостевой токен — не считается авторизованным пользователем.
const isGuest = p?.guest === true
|| (typeof p?.sub === 'string' && p.sub.startsWith('guest_'))
|| p?.type === 'guest';
if (isGuest) return null;
const id = p?.id ?? p?.user_id ?? p?.userId ?? null;
return {
id: id != null ? Number(id) || null : null,
username: p?.firstName || p?.first_name || p?.email || '',
};
} catch (e) { return null; }
})();
const userId = userInfo?.id || null;
// === Realtime-чат (Colyseus room) ===
// Если родитель передал realtimeRoom — используем его, не идём в storys.
useEffect(() => {
if (!realtimeRoom) return;
setTransport('realtime');
setLoaded(true);
setOnline(realtimeRoom.state?.players?.size || 0);
const offChat = realtimeRoom.onMessage('chat', (msg) => {
setMessages(prev => [
...prev,
{
id: Date.now() + Math.random(),
user_id: msg.userId,
username: msg.username,
text: msg.text,
created_at: new Date(msg.ts).toISOString(),
transport: 'realtime',
},
].slice(-200)); // последние 200, не копим бесконечно
});
const offRejected = realtimeRoom.onMessage('chat-rejected', (m) => {
setError(m.text || 'Сообщение не отправлено');
});
// Online-counter обновляется когда игроки заходят/выходят.
// Подсчитываем через state.players через простой interval — точнее,
// подписаться можно через getStateCallbacks, но нам достаточно опроса.
const t = setInterval(() => {
setOnline(realtimeRoom.state?.players?.size || 0);
}, 1000);
return () => {
try { offChat?.(); } catch (e) {}
try { offRejected?.(); } catch (e) {}
clearInterval(t);
};
}, [realtimeRoom]);
// === Socket.IO — fallback для не-мультиплеерных игр ===
// Если WS не подключится за WS_FAILOVER_AFTER_MS, включится polling-fallback.
useEffect(() => {
// Если работаем в realtime-режиме — Socket.IO не нужен
if (realtimeRoom) return;
let alive = true;
let pollTimer = null;
let failoverTimer = null;
const tokenRaw = localStorage.getItem('Authorization') || '';
const token = tokenRaw.startsWith('Bearer ') ? tokenRaw.slice(7) : tokenRaw;
// socket.io-client особенность: при io(url, opts) — из url берётся ТОЛЬКО
// origin + namespace (последний сегмент). Префикс пути отбрасывается, и
// используется ровно opts.path. Поэтому передаём:
// 1) origin + namespace в URL
// 2) полный путь /api-storys/socket.io в opts.path
// Извлекаем origin из STORYS_addres (например https://dev-api.rublox.pro/api-storys)
let storysOrigin;
let storysPath;
try {
const u = new URL(STORYS_addres);
storysOrigin = u.origin; // https://dev-api.rublox.pro
storysPath = (u.pathname || '').replace(/\/$/, '') + '/socket.io';
} catch (e) {
// Fallback (на случай dev-сборки без полного URL)
storysOrigin = STORYS_addres;
storysPath = '/socket.io';
}
const socket = io(storysOrigin + '/kubikon3d/chat', {
path: storysPath,
transports: ['websocket'],
query: { project: projectId, token },
forceNew: true,
reconnection: true,
reconnectionAttempts: 3,
reconnectionDelay: 1000,
});
socketRef.current = socket;
// Если за N секунд WS не подключилось — переходим на polling
failoverTimer = setTimeout(() => {
if (!alive) return;
if (!socket.connected) {
setTransport('polling');
startPolling();
}
}, WS_FAILOVER_AFTER_MS);
socket.on('connect', () => {
if (!alive) return;
setTransport('ws');
clearTimeout(failoverTimer);
// Polling больше не нужен
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
});
socket.on('chat:hello', (payload) => {
if (!alive) return;
const msgs = payload?.messages || [];
setMessages(msgs);
lastIdRef.current = payload?.last_id || 0;
setOnline(payload?.online || 0);
setLoaded(true);
});
socket.on('chat:message', (msg) => {
if (!alive) return;
setMessages(prev => {
// Защита от дублей (если REST-fallback уже добавил)
if (prev.some(x => x.id === msg.id)) return prev;
return [...prev, msg];
});
lastIdRef.current = Math.max(lastIdRef.current, msg.id);
});
socket.on('chat:online', (payload) => {
if (!alive) return;
setOnline(payload?.count || 0);
});
socket.on('chat:muted', (payload) => {
if (!alive) return;
setMuteInfo({
banned_until: payload?.banned_until,
last_reason: payload?.reason,
is_manual: payload?.is_manual,
});
});
socket.on('chat:error', (payload) => {
if (!alive) return;
const m = payload?.message || 'error';
if (m === 'login_required') onRequestAuthRef.current?.('писать в чат');
else if (m === 'email_not_confirmed') setEmailNotice(true);
else if (m === 'too_frequent') setError(payload?.text || 'Слишком быстро.');
else if (m === 'invalid_text') setError('Сообщение пустое.');
else setError(payload?.text || m);
});
socket.on('disconnect', () => {
if (!alive) return;
setTransport('connecting');
});
socket.on('connect_error', () => {
// не падаем сразу — failoverTimer переключит на polling
});
// === Polling-fallback ===
// Для draft-проектов чат недоступен (бэкенд отвечает 403). В этом
// случае НЕ запускаем интервал — иначе он будет каждые 3с отправлять
// HTTP-запрос с 403, что блокирует main thread и виден дёрганый
// куб игрока в GD-уровнях (см. отчёт 2026-05-19).
const startPolling = async () => {
if (pollTimer) return;
let initialFailedAsForbidden = false;
try {
const res = await Kubikon3DApi.getChat(projectId);
if (!alive) return;
setMessages(res.data?.messages || []);
lastIdRef.current = res.data?.last_id || 0;
setLoaded(true);
} catch (e) {
// 403 = project_not_published. Дальнейший polling бессмысленен.
if (e?.response?.status === 403) {
initialFailedAsForbidden = true;
setLoaded(true); // не висим в «загрузка...» бесконечно
}
}
if (initialFailedAsForbidden) return;
pollTimer = setInterval(async () => {
if (!alive) return;
try {
const res = await Kubikon3DApi.getChat(projectId, lastIdRef.current);
const newMsgs = res.data?.messages || [];
if (newMsgs.length > 0) {
setMessages(prev => [...prev, ...newMsgs]);
lastIdRef.current = res.data.last_id;
}
} catch (e) {
// Если стал 403 в процессе (например, статус сменился на draft) —
// прекращаем polling, чтобы не флудить пустыми запросами.
if (e?.response?.status === 403 && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
}, POLL_INTERVAL_MS);
};
// Параллельно проверяем mute-статус (REST), независимо от транспорта
if (userId) {
Kubikon3DApi.getChatMuteStatus(userId)
.then(r => { if (alive && r.data?.muted) setMuteInfo(r.data.ban); })
.catch(() => {});
}
return () => {
alive = false;
clearTimeout(failoverTimer);
if (pollTimer) clearInterval(pollTimer);
try { socket.disconnect(); } catch (e) {}
socketRef.current = null;
};
// ВАЖНО: onRequestAuth НЕ в deps — он берётся из ref. Иначе при каждом
// рендере родителя useEffect пересобирался бы и пересоздавал WS-соединение.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projectId, userId]);
// Автоскролл вниз: при первой загрузке скроллим всегда. При новых сообщениях
// — только если пользователь сам не отскроллил вверх (>200px от низа).
// Используем двойной RAF — фактический scrollHeight доступен только после
// того как браузер применил layout новых сообщений.
const wasFirstScrollRef = useRef(false);
useEffect(() => {
const el = listRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
const isFirst = !wasFirstScrollRef.current && messages.length > 0;
const stayDown = isFirst || distanceFromBottom < 200;
if (stayDown) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
});
});
if (isFirst) wasFirstScrollRef.current = true;
}
}, [messages, loaded]);
const send = async () => {
if (!userId) { onRequestAuth?.('писать в чат'); return; }
const t = text.trim();
if (!t) return;
setError(null);
// === Путь 0: Realtime (Colyseus room) ===
// Если в этом сеансе есть multiplayer-room, шлём через неё.
if (realtimeRoom) {
try {
realtimeRoom.send('chat', { text: t });
setText('');
} catch (e) {
setError('Ошибка отправки в realtime');
}
return;
}
const sock = socketRef.current;
// === Путь 1: WebSocket ===
if (sock && sock.connected) {
sock.emit('chat:send', {
text: t,
username: userInfo?.username || '',
});
// Сообщение прилетит обратно через chat:message broadcast — не дублируем локально
setText('');
return;
}
// === Путь 2: REST-fallback ===
setSubmitting(true);
try {
const res = await Kubikon3DApi.postChatMessage(projectId, {
user_id: userId,
username: userInfo?.username || '',
text: t,
});
const m = res.data?.message;
if (m) {
setMessages(prev => prev.some(x => x.id === m.id) ? prev : [...prev, m]);
lastIdRef.current = Math.max(lastIdRef.current, m.id);
}
setText('');
} catch (e) {
const code = e?.response?.data?.error;
const data = e?.response?.data || {};
if (code === 'muted' || code === 'flood_muted') {
setMuteInfo({
banned_until: data.banned_until,
last_reason: data.reason,
is_manual: data.is_manual,
});
setError(data.message || formatMuteMessage(data));
} else if (code === 'too_frequent') {
setError(data.message || 'Слишком быстро.');
} else if (code === 'login_required') {
onRequestAuth?.('писать в чат');
} else if (code === 'invalid_text') {
setError('Сообщение пустое.');
} else {
setError(data.message || code || 'Ошибка отправки');
}
}
setSubmitting(false);
};
const onKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
};
// Compact mode — плавающее окно 320×380 в левом верхнем углу под top-bar.
// Mobile mode — окно 280×320 в правом верхнем углу. Высота достаточная,
// чтобы кнопка отправки и поле ввода поместились над клавиатурой.
const wrapperStyle = compact
? (mobileMode ? {
position: 'fixed', top: 8, right: 8,
width: 280, height: 320, maxWidth: '90vw', maxHeight: '70vh',
background: CHAT.bgPanel,
border: `1px solid ${CHAT.border}`,
borderRadius: 14,
zIndex: 1100,
display: 'flex', flexDirection: 'column',
color: CHAT.text,
fontFamily: CHAT.font,
boxShadow: '0 8px 24px rgba(0,0,0,0.45)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
overflow: 'hidden',
fontSize: 12,
animation: 'chatSlideIn 280ms cubic-bezier(0.34, 1.56, 0.64, 1)',
} : {
position: 'fixed', top: 146, left: 14,
width: 340, height: 380, maxWidth: '95vw',
background: CHAT.bgPanel,
border: `1px solid ${CHAT.border}`,
borderRadius: 16,
zIndex: 1100,
display: 'flex', flexDirection: 'column',
color: CHAT.text,
fontFamily: CHAT.font,
boxShadow: '0 16px 40px rgba(0,0,0,0.55), 0 0 0 1px rgba(51,87,255,0.12)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
overflow: 'hidden',
animation: 'chatSlideIn 280ms cubic-bezier(0.34, 1.56, 0.64, 1)',
})
: {
position: 'fixed',
top: 0, left: 0, bottom: 0,
width: 380, maxWidth: '95vw',
background: CHAT.bgPanel,
borderRight: `1px solid ${CHAT.border}`,
zIndex: 1600,
display: 'flex', flexDirection: 'column',
color: CHAT.text,
fontFamily: CHAT.font,
boxShadow: '8px 0 32px rgba(0,0,0,0.55)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
overflow: 'hidden',
};
const transportInfo = transport === 'ws'
? { color: CHAT.success, label: 'Live', icon: '●' }
: transport === 'polling'
? { color: CHAT.warning, label: 'Sync', icon: '●' }
: { color: CHAT.textDim, label: '...', icon: '○' };
return (
<div style={wrapperStyle}>
<style>{CHAT_KEYFRAMES}</style>
{/* === Header с градиентной плашкой === */}
<div style={{
padding: '14px 16px',
borderBottom: `1px solid ${CHAT.border}`,
display: 'flex', alignItems: 'center', gap: 10,
background: 'linear-gradient(180deg, rgba(51,87,255,0.16) 0%, rgba(51,87,255,0.03) 100%)',
position: 'relative',
}}>
{/* Тонкая градиентная линия снизу */}
<div style={{
position: 'absolute', left: 0, right: 0, bottom: -1, height: 1,
background: `linear-gradient(90deg, transparent, ${CHAT.accent}, transparent)`,
opacity: 0.6,
}} />
<div style={{
width: 32, height: 32, borderRadius: 10,
background: CHAT.gradientBrand,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 16,
boxShadow: '0 4px 12px rgba(51,87,255,0.45)',
}}>💬</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 14, fontWeight: 800, color: CHAT.text,
letterSpacing: -0.2,
}}>Чат</div>
<div style={{
fontSize: 10, color: CHAT.textMuted, fontWeight: 700,
textTransform: 'uppercase', letterSpacing: 0.8,
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
<span style={{
color: transportInfo.color,
animation: transport === 'ws' ? 'chatPulse 2s ease-in-out infinite' : 'none',
}}>{transportInfo.icon}</span>
{transportInfo.label}
</div>
</div>
{/* Online-pill */}
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: CHAT.successBg,
border: `1px solid ${CHAT.success}33`,
padding: '4px 10px',
borderRadius: 999,
fontSize: 11, fontWeight: 800,
color: CHAT.success,
}}>
<span style={{
width: 6, height: 6, borderRadius: '50%',
background: CHAT.success,
animation: 'chatPulse 2s ease-in-out infinite',
}} />
{online}
</div>
<button
onClick={onClose}
style={iconBtn}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.12)';
e.currentTarget.style.borderColor = CHAT.borderHover;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
e.currentTarget.style.borderColor = CHAT.border;
}}
></button>
</div>
{/* === Список сообщений === */}
<div ref={listRef} style={{
flex: 1, overflowY: 'auto',
padding: '12px 14px',
display: 'flex', flexDirection: 'column', gap: 6,
scrollBehavior: 'smooth',
}}>
{!loaded ? (
<div style={{
margin: 'auto', textAlign: 'center', color: CHAT.textMuted,
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
}}>
<div style={{
width: 22, height: 22,
border: `3px solid ${CHAT.accentBg}`,
borderTopColor: CHAT.accent,
borderRadius: '50%',
animation: 'chatSpin 0.8s linear infinite',
}} />
<div style={{ fontSize: 12, fontWeight: 600 }}>Подключение</div>
</div>
) : messages.filter(m => !m.is_deleted).length === 0 ? (
<div style={{
margin: 'auto', padding: '24px 18px',
textAlign: 'center', color: CHAT.textMuted,
background: 'rgba(51,87,255,0.06)',
borderRadius: 14,
border: `1px dashed ${CHAT.border}`,
maxWidth: 240,
}}>
<div style={{
fontSize: 32, marginBottom: 8,
animation: 'chatFloat 3s ease-in-out infinite',
}}>💭</div>
<div style={{ fontSize: 13, fontWeight: 700, color: CHAT.text, marginBottom: 4 }}>
В чате пока тихо
</div>
<div style={{ fontSize: 11 }}>Напиши первым!</div>
</div>
) : (
messages.filter(m => !m.is_deleted).map((m, i) => (
<ChatMessage key={m.id} m={m} myUserId={userId} />
))
)}
</div>
{/* === Footer с инпутом === */}
<div style={{
borderTop: `1px solid ${CHAT.border}`,
padding: 12,
background: 'rgba(15,19,38,0.6)',
flexShrink: 0,
}}>
{muteInfo && (
<MuteBanner muteInfo={muteInfo} onExpire={() => setMuteInfo(null)} />
)}
{error && !muteInfo && (
<div style={{
background: CHAT.dangerBg,
border: `1px solid ${CHAT.danger}55`,
borderRadius: 10,
padding: '8px 12px',
marginBottom: 10,
fontSize: 12, color: CHAT.danger, fontWeight: 600,
animation: 'chatFadeIn 220ms ease',
}}>
{error}
</div>
)}
{!userId ? (
<div style={{
padding: '12px 14px', textAlign: 'center',
background: CHAT.accentBg,
border: `1px solid ${CHAT.accent}55`,
borderRadius: 12, fontSize: 12, color: CHAT.text, fontWeight: 600,
}}>
🔒 Войди, чтобы общаться в чате
</div>
) : (
<ChatInput
text={text}
onChange={(v) => setText(v.slice(0, 300))}
onSend={send}
onKeyDown={onKeyDown}
disabled={submitting || !!muteInfo}
muteInfo={muteInfo}
/>
)}
</div>
<EmailConfirmNotice
open={emailNotice}
onClose={() => setEmailNotice(false)}
action="писать в чате"
/>
</div>
);
};
// === Стили / палитра / keyframes ===
const CHAT = {
bgPanel: 'rgba(20, 24, 45, 0.92)',
bgInput: 'rgba(15, 19, 38, 0.85)',
bgBubble: 'rgba(255, 255, 255, 0.05)',
bgBubbleMine: 'linear-gradient(135deg, rgba(51,87,255,0.32) 0%, rgba(30,45,165,0.28) 100%)',
border: 'rgba(255, 255, 255, 0.10)',
borderHover:'rgba(255, 255, 255, 0.18)',
text: '#f1f5fb',
textMuted: 'rgba(241, 245, 251, 0.62)',
textDim: 'rgba(241, 245, 251, 0.42)',
accent: '#3357ff',
accentBg: 'rgba(51, 87, 255, 0.18)',
accentHover:'rgba(51, 87, 255, 0.28)',
success: '#22d97a',
successBg: 'rgba(34, 217, 122, 0.18)',
warning: '#ffc857',
danger: '#ff6f7a',
dangerBg: 'rgba(255, 111, 122, 0.16)',
gradientBrand: 'linear-gradient(135deg, #3357ff 0%, #1e2da5 100%)',
font: '"Roboto Condensed", system-ui, -apple-system, sans-serif',
};
const CHAT_KEYFRAMES = `
@keyframes chatFadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@keyframes chatSlideIn { from { opacity: 0; transform: translateX(-12px) scale(0.96); } to { opacity: 1; transform: translateX(0) scale(1); } }
@keyframes chatPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
@keyframes chatSpin { to { transform: rotate(360deg); } }
@keyframes chatFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
`;
/** Цвет аватарки/ника — детерминирован по user_id (HSL). */
function colorForUser(uid) {
if (uid == null) return CHAT.accent;
const h = (uid * 137) % 360;
return `hsl(${h}, 70%, 62%)`;
}
const ChatInput = ({ text, onChange, onSend, onKeyDown, disabled, muteInfo }) => {
const [focus, setFocus] = useState(false);
const [hovered, setHovered] = useState(false);
const canSend = !disabled && text.trim();
return (
<div style={{
display: 'flex', gap: 8, alignItems: 'stretch',
}}>
<input
type="text"
value={text}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
placeholder={muteInfo ? '🔇 Чат заблокирован' : 'Сообщение… (Enter)'}
disabled={disabled}
maxLength={300}
style={{
flex: 1,
background: CHAT.bgInput,
border: `1.5px solid ${focus ? CHAT.accent : CHAT.border}`,
borderRadius: 12,
padding: '10px 14px',
color: CHAT.text,
fontSize: 13, fontWeight: 500, fontFamily: CHAT.font,
outline: 'none',
opacity: muteInfo ? 0.5 : 1,
transition: 'all 150ms ease',
boxShadow: focus ? `0 0 0 3px ${CHAT.accentBg}` : 'none',
}}
/>
<button
onClick={onSend}
disabled={!canSend}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
padding: '0 14px', minWidth: 44,
background: canSend
? CHAT.gradientBrand
: 'rgba(255,255,255,0.06)',
color: canSend ? '#fff' : CHAT.textDim,
border: `1px solid ${canSend ? 'transparent' : CHAT.border}`,
borderRadius: 12,
fontSize: 16, fontWeight: 800,
cursor: canSend ? 'pointer' : 'not-allowed',
fontFamily: CHAT.font,
transition: 'all 200ms ease',
boxShadow: canSend
? (hovered
? '0 8px 20px rgba(51,87,255,0.55)'
: '0 4px 12px rgba(51,87,255,0.35)')
: 'none',
transform: hovered && canSend ? 'translateY(-1px)' : 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
title="Отправить"
>
</button>
</div>
);
};
const ChatMessage = ({ m, myUserId }) => {
const isMine = myUserId === m.user_id;
const userColor = colorForUser(m.user_id);
const initial = (m.username || '?').slice(0, 1).toUpperCase();
return (
<div style={{
display: 'flex', gap: 8, alignItems: 'flex-start',
flexDirection: isMine ? 'row-reverse' : 'row',
animation: 'chatFadeIn 220ms ease',
}}>
{/* Аватар-кружок с инициалом */}
<div style={{
width: 28, height: 28, flexShrink: 0,
borderRadius: '50%',
background: isMine ? CHAT.gradientBrand : userColor,
color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 12, fontWeight: 800,
boxShadow: isMine
? '0 4px 10px rgba(51,87,255,0.40)'
: '0 2px 6px rgba(0,0,0,0.35)',
marginTop: 2,
}}>
{initial}
</div>
{/* Пузырь сообщения */}
<div style={{
background: isMine ? CHAT.bgBubbleMine : CHAT.bgBubble,
border: `1px solid ${isMine ? 'rgba(51,87,255,0.35)' : CHAT.border}`,
borderRadius: 14,
borderTopRightRadius: isMine ? 4 : 14,
borderTopLeftRadius: isMine ? 14 : 4,
padding: '6px 10px 8px',
maxWidth: '78%',
minWidth: 0,
}}>
<div style={{
display: 'flex', alignItems: 'baseline', gap: 6,
flexDirection: isMine ? 'row-reverse' : 'row',
}}>
<a
href={`https://rublox.pro/app/profile/${m.user_id}`}
style={{
fontSize: 11, fontWeight: 800, textDecoration: 'none',
color: isMine ? '#fff' : userColor,
letterSpacing: 0.1,
}}
>
{isMine ? 'Ты' : (m.username || `#${m.user_id}`)}
</a>
<span style={{
fontSize: 9, color: CHAT.textDim, fontWeight: 600,
}}>
{formatTimeShort(m.created_at)}
</span>
</div>
<div style={{
fontSize: 13, color: CHAT.text,
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
marginTop: 2, lineHeight: 1.4,
}}>
{m.text}
</div>
</div>
</div>
);
};
const MuteBanner = ({ muteInfo, onExpire }) => {
const [now, setNow] = useState(Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
if (!muteInfo?.banned_until) return null;
const untilMs = new Date(muteInfo.banned_until.replace(' ', 'T')).getTime();
const remaining = untilMs - now;
if (remaining <= 0) {
setTimeout(onExpire, 0);
return null;
}
const fmt = formatRemaining(remaining);
return (
<div style={{
background: CHAT.dangerBg,
border: `1px solid ${CHAT.danger}55`,
borderRadius: 12,
padding: '10px 12px',
marginBottom: 10,
fontSize: 12, color: CHAT.danger,
display: 'flex', alignItems: 'center', gap: 10,
}}>
<span style={{ fontSize: 18 }}>🔇</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 800 }}>
Мьют чата · <span style={{ fontVariantNumeric: 'tabular-nums' }}>{fmt}</span>
</div>
{muteInfo.last_reason && (
<div style={{
fontSize: 11, color: CHAT.danger, marginTop: 2, opacity: 0.85,
}}>
Причина: {translateReason(muteInfo.last_reason)}
</div>
)}
</div>
</div>
);
};
function formatRemaining(ms) {
const s = Math.ceil(ms / 1000);
if (s < 60) return `${s}с`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}м ${s % 60}с`;
const h = Math.floor(m / 60);
return `${h}ч ${m % 60}м`;
}
function formatMuteMessage(data) {
const r = translateReason(data.reason);
if (data.banned_until) return `🔇 Чат заблокирован до ${data.banned_until} (${r})`;
return `🔇 ${r}`;
}
function translateReason(r) {
if (r === 'profanity') return 'мат';
if (r === 'links') return 'ссылки/спам';
if (r === 'flood') return 'флуд';
if (r === 'caps') return 'CAPS';
if (r === 'manual') return 'вручную модератором';
return r || 'нарушение правил';
}
const iconBtn = {
background: 'rgba(255,255,255,0.04)',
border: `1px solid ${CHAT.border}`,
borderRadius: 8,
color: CHAT.text,
width: 28, height: 28,
cursor: 'pointer',
fontSize: 13, fontWeight: 700,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: CHAT.font,
transition: 'all 150ms ease',
};
export default KubikonChatPanel;