diff --git a/src/games/sync/ui/OnlinePeers.tsx b/src/games/sync/ui/OnlinePeers.tsx deleted file mode 100644 index 269a5ac4..00000000 --- a/src/games/sync/ui/OnlinePeers.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { - Avatar, - Box, - Divider, - Group, - Popover, - Stack, - Text, -} from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; -import { IconCircleFilled } from '@tabler/icons-react'; -import { useMemo } from 'react'; -import { useAllPeers } from '../peersSlice'; -import { SENDER_ID } from '../realtimeSyncTypes'; -import { DeviceIdenticon } from './DeviceIdenticon'; -import { PeerAvatar } from './PeerAvatar'; - -export function OnlinePeers() { - const peers = useAllPeers(); - const [opened, { toggle, close }] = useDisclosure(false); - - const deviceCountByUser = useMemo(() => { - const map = new Map(); - for (const p of peers) { - map.set(p.userId, (map.get(p.userId) ?? 0) + 1); - } - return map; - }, [peers]); - - const leaderSenderId = useMemo(() => { - if (peers.length === 0) return null; - return [...peers.map(p => p.senderId)].sort()[0]; - }, [peers]); - - const groupedByUser = useMemo(() => { - const groups = new Map< - string, - { - userId: string; - displayName: string; - avatarUrl: string | null; - devices: typeof peers; - } - >(); - for (const p of peers) { - const existing = groups.get(p.userId); - if (existing) { - existing.devices.push(p); - } else { - groups.set(p.userId, { - userId: p.userId, - displayName: p.displayName, - avatarUrl: p.avatarUrl, - devices: [p], - }); - } - } - return Array.from(groups.values()); - }, [peers]); - - if (peers.length === 0) return null; - - return ( - - - - {peers.map((peer, idx) => ( - - 1} - /> - - ))} - - - - - - Connected ({peers.length}) - - {groupedByUser.map((group, idx) => ( - - {idx > 0 && } - - - {group.displayName.charAt(0).toUpperCase()} - - - {group.displayName} - {group.devices.some(d => d.senderId === SENDER_ID) && ( - - {' '} - (you) - - )} - - - - {group.devices.map(device => ( - - - - {device.deviceName || 'Unknown device'} - - {device.senderId === leaderSenderId && ( - - )} - - ))} - - - ))} - - - - ); -} diff --git a/src/games/sync/ui/OnlinePeersList.module.css b/src/games/sync/ui/OnlinePeersList.module.css new file mode 100644 index 00000000..894d527c --- /dev/null +++ b/src/games/sync/ui/OnlinePeersList.module.css @@ -0,0 +1,12 @@ +.identiconBadge { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--mantine-color-dark-5); + border: 2px solid var(--mantine-color-dark-7); + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; + flex-shrink: 0; +} diff --git a/src/games/sync/ui/OnlinePeersList.tsx b/src/games/sync/ui/OnlinePeersList.tsx new file mode 100644 index 00000000..701e38df --- /dev/null +++ b/src/games/sync/ui/OnlinePeersList.tsx @@ -0,0 +1,90 @@ +import { Avatar, Box, Divider, Group, Stack, Text } from '@mantine/core'; +import { IconCircleFilled } from '@tabler/icons-react'; +import { useMemo } from 'react'; +import { useAllPeers } from '../peersSlice'; +import { SENDER_ID } from '../realtimeSyncTypes'; +import { DeviceIdenticon } from './DeviceIdenticon'; +import classes from './OnlinePeersList.module.css'; + +const IDENTICON_SIZE = 14; + +export function OnlinePeersList() { + const peers = useAllPeers(); + + const leaderSenderId = useMemo(() => { + if (peers.length === 0) return null; + return [...peers.map(p => p.senderId)].sort()[0]; + }, [peers]); + + const groupedByUser = useMemo(() => { + const groups = new Map< + string, + { + userId: string; + displayName: string; + avatarUrl: string | null; + devices: typeof peers; + } + >(); + for (const p of peers) { + const existing = groups.get(p.userId); + if (existing) { + existing.devices.push(p); + } else { + groups.set(p.userId, { + userId: p.userId, + displayName: p.displayName, + avatarUrl: p.avatarUrl, + devices: [p], + }); + } + } + return Array.from(groups.values()); + }, [peers]); + + return ( + + {groupedByUser.map((group, idx) => ( + + {idx > 0 && } + + + {group.displayName.charAt(0).toUpperCase()} + + + {group.displayName} + {group.devices.some(d => d.senderId === SENDER_ID) && ( + + {' '} + (you) + + )} + + + + {group.devices.map(device => ( + + + + + + {device.deviceName || 'Unknown device'} + + {device.senderId === leaderSenderId && ( + + )} + + ))} + + + ))} + + ); +} diff --git a/src/games/sync/ui/PeerAvatar.module.css b/src/games/sync/ui/PeerAvatar.module.css new file mode 100644 index 00000000..bdb8c647 --- /dev/null +++ b/src/games/sync/ui/PeerAvatar.module.css @@ -0,0 +1,22 @@ +.wrap { + position: relative; + display: inline-flex; + width: var(--peer-avatar-size); + height: var(--peer-avatar-size); + vertical-align: middle; +} + +.badge { + position: absolute; + bottom: -2px; + right: -2px; + width: var(--peer-avatar-badge-size); + height: var(--peer-avatar-badge-size); + border-radius: 50%; + border: 2px solid var(--mantine-color-dark-7); + background: var(--mantine-color-dark-5); + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/games/sync/ui/PeerAvatar.tsx b/src/games/sync/ui/PeerAvatar.tsx index 47584643..dbc4c2ff 100644 --- a/src/games/sync/ui/PeerAvatar.tsx +++ b/src/games/sync/ui/PeerAvatar.tsx @@ -1,7 +1,9 @@ import { Avatar, Box, Tooltip } from '@mantine/core'; +import type { CSSProperties } from 'react'; import type { PeerInfo } from '../peersSlice'; import { SENDER_ID } from '../realtimeSyncTypes'; import { DeviceIdenticon } from './DeviceIdenticon'; +import classes from './PeerAvatar.module.css'; export interface PeerAvatarProps { peer: PeerInfo; @@ -35,31 +37,16 @@ export function PeerAvatar({ const content = showDeviceBadge && peer.deviceName ? ( {avatar} - + s.gameSave.isRealtimeSyncConnected); const peers = useAllPeers(); + const deviceCountByUser = useMemo(() => { + const map = new Map(); + for (const p of peers) { + map.set(p.userId, (map.get(p.userId) ?? 0) + 1); + } + return map; + }, [peers]); + if (!isConnected && peers.length === 0) return null; + const hasPeers = peers.length > 0; + return ( - - - - {isConnected ? ( - <> - + + + {isConnected ? ( + <> + + + + ) : ( + + )} + + {hasPeers && ( + + {peers.map((peer, idx) => ( + + 1 + } + /> + + ))} + + )} + + + + + + {isConnected ? ( + - + )} + + {isConnected ? 'Realtime sync active' : 'Realtime sync offline'} + {hasPeers && ( + + {' ยท '} + {peers.length} connected + + )} + + + {hasPeers && ( + <> + + - ) : ( - )} - - - {peers.length > 0 && } - + + + ); } diff --git a/src/games/sync/useRealtimeGameSync.ts b/src/games/sync/useRealtimeGameSync.ts index fed8b020..d47c44ed 100644 --- a/src/games/sync/useRealtimeGameSync.ts +++ b/src/games/sync/useRealtimeGameSync.ts @@ -245,6 +245,26 @@ export function useRealtimeGameSync() { }; window.addEventListener('beforeunload', onBeforeUnload); + // Supabase's channel heartbeat can take 30s+ to detect a dead WebSocket, + // so we flip the sync-connected flag off immediately when the browser + // reports offline, and force an early reconnect attempt when it comes back. + const onOffline = () => { + logger.warn('Browser went offline: marking sync disconnected'); + useStore.getState().setRealtimeSyncConnected(false); + }; + + const onOnline = () => { + logger.info('Browser back online: forcing reconnect'); + reconnectAttemptsRef.current = 0; + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + setReconnectEpoch(e => e + 1); + }; + window.addEventListener('offline', onOffline); + window.addEventListener('online', onOnline); + const unsubscribePatches = onStorePatches(patches => { if (isApplyingRemoteRef.current || isBroadcastSuppressed()) return; if (!channelRef.current) return; @@ -262,6 +282,8 @@ export function useRealtimeGameSync() { return () => { isCleaningUp = true; window.removeEventListener('beforeunload', onBeforeUnload); + window.removeEventListener('offline', onOffline); + window.removeEventListener('online', onOnline); unsubscribePatches(); if (flushTimer !== null) { clearTimeout(flushTimer);