/** * 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 { formatSince } from '@global-utils/format'; import Dialog from '@mui/material/Dialog'; import { styled } from '@mui/material/styles'; import { useLobbyDataProvider } from '@mine-hooks'; import { bool, func, string } from 'prop-types'; const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc, isEnvDev = false }) => { 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 { waitingPlayersQuery, challengeMutation } = useLobbyDataProvider(); 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); waitingPlayersQuery.refetch().then(result => { if (result.data) { // Filter out current user's game from the snapshot const filtered = result.data.filter(p => p.gameAssoc !== currentGameAssoc); setPlayers(filtered); } setSnapshotLoaded(true); setLoading(false); }).catch(() => { setPlayers([]); setSnapshotLoaded(true); setLoading(false); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [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(() => { if (challengeMutation.isError) { setChallengingGameAssoc(null); setWaitingCountdown(0); } }, [challengeMutation.isError]); 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); challengeMutation.mutate( { targetGameAssoc: player.gameAssoc, challengerGameAssoc: currentGameAssoc } ); }; 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 (isEnvDev && 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 (
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

)}
); }; const StyledDialog = styled(Dialog)({ '& .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)', }, }); export default OnlinePlayersDialog; OnlinePlayersDialog.propTypes = { open: bool.isRequired, onClose: func.isRequired, currentGameAssoc: string, isEnvDev: bool, };