/** * This file is part of the SplendidBear Websites' projects. * * Copyright (c) 2026 @ www.splendidbear.org * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import Dialog from '@mui/material/Dialog'; const DIALOG_SX = { '& .MuiDialog-paper': { background: '#07090d', backgroundImage: ` linear-gradient(rgba(35, 111, 135, 0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(35, 111, 135, 0.08) 1px, transparent 1px) `, backgroundSize: '46px 46px', border: '1px solid rgba(35, 111, 135, 0.4)', borderRadius: '12px', boxShadow: '0 0 80px rgba(35, 111, 135, 0.15), 0 32px 80px rgba(0, 0, 0, 0.9)', width: '500px', maxWidth: '94vw', overflow: 'hidden', color: '#fff', }, '& .MuiBackdrop-root': { background: 'rgba(2, 4, 8, 0.88)', backdropFilter: 'blur(4px)', }, }; const formatSince = isoStr => { const diff = Math.floor((Date.now() - new Date(isoStr)) / 60000); if (1 > diff) return 'just now'; if (1 === diff) return '1 min ago'; return `${diff} min ago`; }; const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc }) => { const [players, setPlayers] = useState([]); const [search, setSearch] = useState(''); const [loading, setLoading] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const [snapshotLoaded, setSnapshotLoaded] = useState(false); const [challengingGameAssoc, setChallengingGameAssoc] = useState(null); const [declinedMsg, setDeclinedMsg] = useState(''); const [waitingCountdown, setWaitingCountdown] = useState(0); const declinedTimerRef = useRef(null); const addPlayer = useCallback(entry => { setPlayers(prev => prev.some(p => p.gameAssoc === entry.gameAssoc) ? prev : [...prev, entry], ); }, []); const removePlayer = useCallback(gameAssoc => { setPlayers(prev => prev.filter(p => p.gameAssoc !== gameAssoc)); }, []); useEffect(() => { if (!open) return; setLoading(true); setSnapshotLoaded(false); fetch('/api/game/waiting') .then(r => r.json()) .then(data => { // Filter out current user's game from the snapshot const filtered = data.filter(p => p.gameAssoc !== currentGameAssoc); setPlayers(filtered); setSnapshotLoaded(true); setLoading(false); }) .catch(() => { setPlayers([]); setSnapshotLoaded(true); setLoading(false); }); }, [open, refreshKey, currentGameAssoc]); useEffect(() => { if (!open || !snapshotLoaded) return; setSearch(''); const wrapper = document.getElementById('mine-wrapper'); const hubUrl = wrapper?.dataset?.mercureHubUrl; const jwt = wrapper?.dataset?.mercureSubscriberJwt; if (!hubUrl) return; const url = new URL(hubUrl, window.location.origin); url.searchParams.append('topic', 'mineseeker/lobby'); if (jwt) url.searchParams.append('authorization', jwt); const es = new EventSource(url.toString()); es.onmessage = e => { const { action, gameAssoc, name, since } = JSON.parse(e.data); // Don't add the current user's game to the list if (gameAssoc === currentGameAssoc) return; if ('join' === action) addPlayer({ gameAssoc, name, since }); if ('leave' === action) removePlayer(gameAssoc); }; return () => es.close(); }, [open, snapshotLoaded, addPlayer, removePlayer, currentGameAssoc]); useEffect(() => { const handler = () => { setChallengingGameAssoc(null); clearTimeout(declinedTimerRef.current); setDeclinedMsg('Challenge was not accepted.'); setWaitingCountdown(0); declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500); }; window.addEventListener('challenge-declined', handler); return () => { window.removeEventListener('challenge-declined', handler); clearTimeout(declinedTimerRef.current); }; }, []); useEffect(() => { if (!waitingCountdown) return; const interval = setInterval(() => { setWaitingCountdown(prev => { if (1 >= prev) return 0; return prev - 1; }); }, 1000); return () => clearInterval(interval); }, [waitingCountdown]); const handleChallenge = player => { if (challengingGameAssoc) return; setChallengingGameAssoc(player.gameAssoc); setDeclinedMsg(''); setWaitingCountdown(30); fetch('/api/game/challenge/' + player.gameAssoc, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ challengerGameAssoc: currentGameAssoc }), }).catch(() => { setChallengingGameAssoc(null); setWaitingCountdown(0); }); }; const visible = players .filter(p => p.gameAssoc !== currentGameAssoc) .filter(p => p.name.toLowerCase().includes(search.toLowerCase())); const shown = visible.slice(0, 5); const hasMore = 5 < visible.length; // Debug: log if currentGameAssoc is undefined or if current user appears if ('development' === process.env.NODE_ENV && 0 < players.length) { const userInList = players.find(p => p.gameAssoc === currentGameAssoc); if (userInList) { console.warn('[OnlinePlayersDialog] Current user appears in players list:', { currentGameAssoc, userInList }); } } return ( 0 ? undefined : onClose} disableEscapeKeyDown={waitingCountdown > 0} sx={DIALOG_SX} >
Multiplayer

Online Players

{0 < waitingCountdown ? (

Waiting {waitingCountdown} second{1 === waitingCountdown ? '' : 's'} for opponent's answer...

) : (
setSearch(e.target.value)} /> {search && ( )}
)}
{loading && (

Loading players…

)} {!loading && 0 === shown.length && (

{search ? 'No players match your search.' : 'No other players are waiting right now.'}

)} {declinedMsg && (
{' '}{declinedMsg}
)} {!loading && shown.map(player => { const isWaiting = challengingGameAssoc === player.gameAssoc; return (
{player.name.slice(0, 2).toUpperCase()}
{player.name} {' '}Waiting {formatSince(player.since)}
); })}
{!loading && hasMore && (

Showing 5 of {visible.length} waiting players

)}
); }; export default OnlinePlayersDialog;