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_. * Если 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 (
{/* === Header с градиентной плашкой === */}
{/* Тонкая градиентная линия снизу */}
💬
Чат
{transportInfo.icon} {transportInfo.label}
{/* Online-pill */}
{online}
{/* === Список сообщений === */}
{!loaded ? (
Подключение…
) : messages.filter(m => !m.is_deleted).length === 0 ? (
💭
В чате пока тихо
Напиши первым!
) : ( messages.filter(m => !m.is_deleted).map((m, i) => ( )) )}
{/* === Footer с инпутом === */}
{muteInfo && ( setMuteInfo(null)} /> )} {error && !muteInfo && (
⚠️ {error}
)} {!userId ? (
🔒 Войди, чтобы общаться в чате
) : ( setText(v.slice(0, 300))} onSend={send} onKeyDown={onKeyDown} disabled={submitting || !!muteInfo} muteInfo={muteInfo} /> )}
setEmailNotice(false)} action="писать в чате" />
); }; // === Стили / палитра / 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 (
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', }} />
); }; 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 (
{/* Аватар-кружок с инициалом */}
{initial}
{/* Пузырь сообщения */}
{isMine ? 'Ты' : (m.username || `#${m.user_id}`)} {formatTimeShort(m.created_at)}
{m.text}
); }; 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 (
🔇
Мьют чата · {fmt}
{muteInfo.last_reason && (
Причина: {translateReason(muteInfo.last_reason)}
)}
); }; 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;