2026-04-11 22:20:21 +02:00
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-04-12 08:01:46 +02:00
|
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
2026-04-21 11:30:07 +02:00
|
|
|
import { formatSince } from '@global-utils/format';
|
2026-04-11 22:20:21 +02:00
|
|
|
import Dialog from '@mui/material/Dialog';
|
2026-04-21 11:30:07 +02:00
|
|
|
import { styled } from '@mui/material/styles';
|
2026-04-19 20:56:51 +02:00
|
|
|
import { useLobbyDataProvider } from '@mine-hooks';
|
2026-04-21 11:30:07 +02:00
|
|
|
import { bool, func, string } from 'prop-types';
|
2026-04-11 22:20:21 +02:00
|
|
|
|
2026-04-19 20:56:51 +02:00
|
|
|
const OnlinePlayersDialog = ({ open, onClose, currentGameAssoc, isEnvDev = false }) => {
|
2026-04-11 22:20:21 +02:00
|
|
|
const [players, setPlayers] = useState([]);
|
|
|
|
|
const [search, setSearch] = useState('');
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
|
|
|
const [snapshotLoaded, setSnapshotLoaded] = useState(false);
|
2026-04-12 08:01:46 +02:00
|
|
|
const [challengingGameAssoc, setChallengingGameAssoc] = useState(null);
|
|
|
|
|
const [declinedMsg, setDeclinedMsg] = useState('');
|
2026-04-14 20:30:18 +02:00
|
|
|
const [waitingCountdown, setWaitingCountdown] = useState(0);
|
2026-04-12 08:01:46 +02:00
|
|
|
const declinedTimerRef = useRef(null);
|
2026-04-19 20:56:51 +02:00
|
|
|
const { waitingPlayersQuery, challengeMutation } = useLobbyDataProvider();
|
2026-04-11 22:20:21 +02:00
|
|
|
|
|
|
|
|
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);
|
2026-04-19 20:56:51 +02:00
|
|
|
|
|
|
|
|
waitingPlayersQuery.refetch().then(result => {
|
|
|
|
|
if (result.data) {
|
2026-04-11 22:20:21 +02:00
|
|
|
// Filter out current user's game from the snapshot
|
2026-04-19 20:56:51 +02:00
|
|
|
const filtered = result.data.filter(p => p.gameAssoc !== currentGameAssoc);
|
2026-04-11 22:20:21 +02:00
|
|
|
setPlayers(filtered);
|
2026-04-19 20:56:51 +02:00
|
|
|
}
|
|
|
|
|
setSnapshotLoaded(true);
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}).catch(() => {
|
|
|
|
|
setPlayers([]);
|
|
|
|
|
setSnapshotLoaded(true);
|
|
|
|
|
setLoading(false);
|
|
|
|
|
});
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2026-04-11 22:20:21 +02:00
|
|
|
}, [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]);
|
|
|
|
|
|
2026-04-19 20:56:51 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (challengeMutation.isError) {
|
|
|
|
|
setChallengingGameAssoc(null);
|
|
|
|
|
setWaitingCountdown(0);
|
|
|
|
|
}
|
|
|
|
|
}, [challengeMutation.isError]);
|
|
|
|
|
|
2026-04-12 08:01:46 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
const handler = () => {
|
|
|
|
|
setChallengingGameAssoc(null);
|
|
|
|
|
clearTimeout(declinedTimerRef.current);
|
|
|
|
|
setDeclinedMsg('Challenge was not accepted.');
|
2026-04-14 20:30:18 +02:00
|
|
|
setWaitingCountdown(0);
|
2026-04-12 08:01:46 +02:00
|
|
|
declinedTimerRef.current = setTimeout(() => setDeclinedMsg(''), 3500);
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener('challenge-declined', handler);
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('challenge-declined', handler);
|
|
|
|
|
clearTimeout(declinedTimerRef.current);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-14 20:30:18 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!waitingCountdown) return;
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
setWaitingCountdown(prev => {
|
|
|
|
|
if (1 >= prev) return 0;
|
|
|
|
|
return prev - 1;
|
|
|
|
|
});
|
|
|
|
|
}, 1000);
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
}, [waitingCountdown]);
|
|
|
|
|
|
2026-04-12 08:01:46 +02:00
|
|
|
const handleChallenge = player => {
|
|
|
|
|
if (challengingGameAssoc) return;
|
|
|
|
|
setChallengingGameAssoc(player.gameAssoc);
|
|
|
|
|
setDeclinedMsg('');
|
2026-04-14 20:30:18 +02:00
|
|
|
setWaitingCountdown(30);
|
2026-04-19 20:56:51 +02:00
|
|
|
|
|
|
|
|
challengeMutation.mutate(
|
|
|
|
|
{ targetGameAssoc: player.gameAssoc, challengerGameAssoc: currentGameAssoc }
|
|
|
|
|
);
|
2026-04-12 08:01:46 +02:00
|
|
|
};
|
|
|
|
|
|
2026-04-11 22:20:21 +02:00
|
|
|
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
|
2026-04-19 20:56:51 +02:00
|
|
|
if (isEnvDev && 0 < players.length) {
|
2026-04-11 22:20:21 +02:00
|
|
|
const userInList = players.find(p => p.gameAssoc === currentGameAssoc);
|
2026-04-19 20:56:51 +02:00
|
|
|
|
2026-04-11 22:20:21 +02:00
|
|
|
if (userInList) {
|
|
|
|
|
console.warn('[OnlinePlayersDialog] Current user appears in players list:', { currentGameAssoc, userInList });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-21 11:30:07 +02:00
|
|
|
<StyledDialog
|
2026-04-19 20:56:51 +02:00
|
|
|
open={open}
|
|
|
|
|
onClose={0 < waitingCountdown ? undefined : onClose}
|
2026-04-14 21:04:05 +02:00
|
|
|
>
|
2026-04-11 22:20:21 +02:00
|
|
|
<div className="opd">
|
|
|
|
|
<div className="opd-header">
|
|
|
|
|
<div className="opd-header-text">
|
|
|
|
|
<span className="opd-label">Multiplayer</span>
|
|
|
|
|
<h2 className="opd-title">
|
|
|
|
|
<i className="fa fa-users" />
|
|
|
|
|
Online Players
|
|
|
|
|
</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="opd-header-actions">
|
|
|
|
|
<button
|
|
|
|
|
className={`opd-refresh${loading ? ' opd-refresh--spin' : ''}`}
|
2026-04-14 21:04:05 +02:00
|
|
|
onClick={() => { if (0 === waitingCountdown) setRefreshKey(k => k + 1); }}
|
|
|
|
|
disabled={loading || 0 < waitingCountdown}
|
2026-04-11 22:20:21 +02:00
|
|
|
aria-label="Refresh"
|
|
|
|
|
title="Refresh list"
|
|
|
|
|
>
|
|
|
|
|
<i className="fa fa-refresh" />
|
|
|
|
|
</button>
|
2026-04-19 20:56:51 +02:00
|
|
|
<button
|
|
|
|
|
className="opd-close"
|
|
|
|
|
onClick={() => { if (0 === waitingCountdown) onClose(); }}
|
2026-04-14 21:04:05 +02:00
|
|
|
disabled={0 < waitingCountdown}
|
2026-04-14 20:30:18 +02:00
|
|
|
aria-label="Close"
|
|
|
|
|
>
|
2026-04-11 22:20:21 +02:00
|
|
|
<i className="fa fa-times" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-04-14 20:30:18 +02:00
|
|
|
{0 < waitingCountdown ? (
|
|
|
|
|
<div className="opd-waiting">
|
|
|
|
|
<i className="fa fa-hourglass-start" />
|
|
|
|
|
<p>Waiting {waitingCountdown} second{1 === waitingCountdown ? '' : 's'} for opponent's answer...</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="opd-search-wrap">
|
|
|
|
|
<i className="fa fa-search opd-search-icon" />
|
|
|
|
|
<input
|
|
|
|
|
className="opd-search"
|
|
|
|
|
placeholder="Search by username…"
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={e => setSearch(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
{search && (
|
|
|
|
|
<button className="opd-search-clear" onClick={() => setSearch('')}>
|
|
|
|
|
<i className="fa fa-times" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-04-11 22:20:21 +02:00
|
|
|
<div className="opd-list">
|
|
|
|
|
{loading && (
|
|
|
|
|
<div className="opd-empty">
|
|
|
|
|
<i className="fa fa-spinner fa-spin" />
|
|
|
|
|
<p>Loading players…</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!loading && 0 === shown.length && (
|
|
|
|
|
<div className="opd-empty">
|
|
|
|
|
<i className="fa fa-hourglass-half" />
|
|
|
|
|
<p>
|
|
|
|
|
{search
|
|
|
|
|
? 'No players match your search.'
|
|
|
|
|
: 'No other players are waiting right now.'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-04-12 08:01:46 +02:00
|
|
|
{declinedMsg && (
|
|
|
|
|
<div className="opd-declined">
|
|
|
|
|
<i className="fa fa-times-circle" />
|
|
|
|
|
{' '}{declinedMsg}
|
2026-04-11 22:20:21 +02:00
|
|
|
</div>
|
2026-04-12 08:01:46 +02:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!loading && shown.map(player => {
|
|
|
|
|
const isWaiting = challengingGameAssoc === player.gameAssoc;
|
|
|
|
|
return (
|
|
|
|
|
<div key={player.gameAssoc} className="opd-row">
|
|
|
|
|
<div className="opd-avatar">
|
|
|
|
|
{player.name.slice(0, 2).toUpperCase()}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="opd-info">
|
|
|
|
|
<span className="opd-name">{player.name}</span>
|
|
|
|
|
<span className="opd-since">
|
2026-04-19 18:04:01 +02:00
|
|
|
<i className="fa fa-clock" />
|
2026-04-12 08:01:46 +02:00
|
|
|
{' '}Waiting {formatSince(player.since)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
className={`opd-join${isWaiting ? ' opd-join--waiting' : ''}`}
|
|
|
|
|
onClick={() => handleChallenge(player)}
|
|
|
|
|
disabled={!!challengingGameAssoc}
|
|
|
|
|
>
|
|
|
|
|
<i className={`fa ${isWaiting ? 'fa-spinner fa-spin' : 'fa-play'}`} />
|
|
|
|
|
{isWaiting ? 'Waiting...' : 'Join'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-04-11 22:20:21 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!loading && hasMore && (
|
|
|
|
|
<p className="opd-note">
|
|
|
|
|
Showing 5 of {visible.length} waiting players
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-04-21 11:30:07 +02:00
|
|
|
</StyledDialog>
|
2026-04-11 22:20:21 +02:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-21 11:30:07 +02:00
|
|
|
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)',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-11 22:20:21 +02:00
|
|
|
export default OnlinePlayersDialog;
|
2026-04-21 11:30:07 +02:00
|
|
|
|
|
|
|
|
OnlinePlayersDialog.propTypes = {
|
|
|
|
|
open: bool.isRequired,
|
|
|
|
|
onClose: func.isRequired,
|
|
|
|
|
currentGameAssoc: string,
|
|
|
|
|
isEnvDev: bool,
|
|
|
|
|
};
|