From 040aa7378723bd919857bb42903c79c7455e00b5 Mon Sep 17 00:00:00 2001 From: rockfactory Date: Thu, 16 Apr 2026 00:47:14 +0200 Subject: [PATCH] refactored online peers ui --- package-lock.json | 10 + package.json | 1 + src/factories/list/FactoryGridCard.tsx | 2 +- src/factories/list/FactoryRow.tsx | 2 +- src/games/menu/GameMenu.tsx | 210 +++++++++----------- src/games/sync/FactoryPeers.tsx | 23 --- src/games/sync/OnlinePeers.tsx | 19 -- src/games/sync/deviceIdentity.ts | 94 +++++++++ src/games/sync/peersSlice.ts | 27 ++- src/games/sync/realtimeSyncHandlers.ts | 17 +- src/games/sync/realtimeSyncTypes.ts | 3 +- src/games/sync/ui/DeviceIdenticon.tsx | 35 ++++ src/games/sync/ui/FactoryPeers.tsx | 42 ++++ src/games/sync/ui/OnlinePeers.tsx | 142 +++++++++++++ src/games/sync/ui/PeerAvatar.tsx | 81 ++++++++ src/games/sync/ui/RealtimeSyncIndicator.tsx | 66 ++++++ src/games/sync/useRealtimeGameSync.ts | 3 + src/layout/Header.tsx | 22 +- 18 files changed, 615 insertions(+), 184 deletions(-) delete mode 100644 src/games/sync/FactoryPeers.tsx delete mode 100644 src/games/sync/OnlinePeers.tsx create mode 100644 src/games/sync/deviceIdentity.ts create mode 100644 src/games/sync/ui/DeviceIdenticon.tsx create mode 100644 src/games/sync/ui/FactoryPeers.tsx create mode 100644 src/games/sync/ui/OnlinePeers.tsx create mode 100644 src/games/sync/ui/PeerAvatar.tsx create mode 100644 src/games/sync/ui/RealtimeSyncIndicator.tsx diff --git a/package-lock.json b/package-lock.json index 2a593420..84a72336 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "loglevel": "^1.9.2", "loglevel-plugin-prefix": "^0.8.4", "microdiff": "^1.5.0", + "minidenticons": "^4.2.1", "moize": "^6.1.7", "pako": "^2.1.0", "react": "^19.2.4", @@ -6061,6 +6062,15 @@ "node": ">=4" } }, + "node_modules/minidenticons": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/minidenticons/-/minidenticons-4.2.1.tgz", + "integrity": "sha512-oWfFivA0lOx/V/bO/YIJbthB26lV8JXYvhnv9zM2hNd3fzsHTXQ6c6bWZPcvhD3nnOB+lQk/D9lF43BXixrN8g==", + "license": "MIT", + "engines": { + "node": ">=15.14.0" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", diff --git a/package.json b/package.json index dcb0ac2d..76d58cce 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "loglevel": "^1.9.2", "loglevel-plugin-prefix": "^0.8.4", "microdiff": "^1.5.0", + "minidenticons": "^4.2.1", "moize": "^6.1.7", "pako": "^2.1.0", "react": "^19.2.4", diff --git a/src/factories/list/FactoryGridCard.tsx b/src/factories/list/FactoryGridCard.tsx index be631b96..4c68202c 100644 --- a/src/factories/list/FactoryGridCard.tsx +++ b/src/factories/list/FactoryGridCard.tsx @@ -6,7 +6,7 @@ import { useStore } from '@/core/zustand'; import { ProgressChip } from '@/factories/components/ProgressChip'; import type { Factory } from '@/factories/Factory'; import { useIsFactoryVisible } from '@/factories/useIsFactoryVisible'; -import { FactoryPeers } from '@/games/sync/FactoryPeers'; +import { FactoryPeers } from '@/games/sync/ui/FactoryPeers'; import { FactoryItemImage } from '@/recipes/ui/FactoryItemImage'; import classes from './FactoryGridCard.module.css'; diff --git a/src/factories/list/FactoryRow.tsx b/src/factories/list/FactoryRow.tsx index 4d22241d..05b395f3 100644 --- a/src/factories/list/FactoryRow.tsx +++ b/src/factories/list/FactoryRow.tsx @@ -24,7 +24,7 @@ import { FactoryInputRow } from '@/factories/inputs/input-row/FactoryInputRow'; import { FactoryOutputRow } from '@/factories/inputs/output-row/FactoryOutputRow'; import { useIsFactoryVisible } from '@/factories/useIsFactoryVisible'; import { useGameFactoryIsCollapsed } from '@/games/gamesSlice'; -import { FactoryPeers } from '@/games/sync/FactoryPeers'; +import { FactoryPeers } from '@/games/sync/ui/FactoryPeers'; export interface IFactoryRowProps { id: string; diff --git a/src/games/menu/GameMenu.tsx b/src/games/menu/GameMenu.tsx index 9a9a0a48..97230324 100644 --- a/src/games/menu/GameMenu.tsx +++ b/src/games/menu/GameMenu.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Menu } from '@mantine/core'; +import { Button, Menu } from '@mantine/core'; import { useDisclosure } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; import { @@ -55,9 +55,6 @@ export function GameMenu(props: IGameMenuProps) { state => !!state.games.games[selectedId ?? '']?.savedId, ); const isSaving = useStore(state => state.gameSave.isSaving); - const isSyncConnected = useStore( - state => state.gameSave.isRealtimeSyncConnected, - ); const navigate = useNavigate(); const [opened, { toggle, open, close }] = useDisclosure(); @@ -96,122 +93,113 @@ export function GameMenu(props: IGameMenuProps) { return ( <> - - - - - - - - Change game - {gameOptions.map(option => ( - } - onClick={() => { - useStore.getState().selectGame(option.value); - navigate(`/factories`); - }} - rightSection={ - selectedId === option.value && ( - - ) - } - > - {option.label} - - ))} - - { - useStore.getState().createGame(v4(), { - name: - 'New Game ' + - (Object.keys(useStore.getState().games.games).length + 1), - }); - }} - leftSection={} - > - New game - - - Game actions + + + + + + + Change game + {gameOptions.map(option => ( - } + key={option.value} + leftSection={} onClick={() => { - open(); + useStore.getState().selectGame(option.value); + navigate(`/factories`); }} - > - Rename game - - + rightSection={ + selectedId === option.value && ( + + ) } - onClick={openGameSettingsModal} - > - Game settings - - } - onClick={() => handleSaveGame(selectedId)} > - Save game + {option.label} - {selectedId && isSelectedSavedOnRemote && ( - } - onClick={() => handleLoadGame(selectedId)} - > - Load last save - - )} - + ))} + + { + useStore.getState().createGame(v4(), { + name: + 'New Game ' + + (Object.keys(useStore.getState().games.games).length + 1), + }); + }} + leftSection={} + > + New game + + + Game actions + + } + onClick={() => { + open(); + }} + > + Rename game + + + } + onClick={openGameSettingsModal} + > + Game settings + + } + onClick={() => handleSaveGame(selectedId)} + > + Save game + + {selectedId && isSelectedSavedOnRemote && ( } - onClick={() => { - navigate(`/games`); - }} + leftSection={} + onClick={() => handleLoadGame(selectedId)} > - Games list + Load last save - - - - - {isSyncConnected && ( - - )} - + )} + + } + onClick={() => { + navigate(`/games`); + }} + > + Games list + + + + + {selectedId && ( )} diff --git a/src/games/sync/FactoryPeers.tsx b/src/games/sync/FactoryPeers.tsx deleted file mode 100644 index 437a4b38..00000000 --- a/src/games/sync/FactoryPeers.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Avatar, Tooltip } from '@mantine/core'; -import { useFactoryPeers } from './peersSlice'; - -export interface FactoryPeersProps { - factoryId: string; -} - -export function FactoryPeers({ factoryId }: FactoryPeersProps) { - const peers = useFactoryPeers(factoryId); - if (peers.length === 0) return null; - - return ( - - {peers.map(peer => ( - - - {peer.displayName.charAt(0).toUpperCase()} - - - ))} - - ); -} diff --git a/src/games/sync/OnlinePeers.tsx b/src/games/sync/OnlinePeers.tsx deleted file mode 100644 index f2103437..00000000 --- a/src/games/sync/OnlinePeers.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { Avatar, Tooltip } from '@mantine/core'; -import { useOnlinePeers } from './peersSlice'; - -export function OnlinePeers() { - const peers = useOnlinePeers(); - if (peers.length === 0) return null; - - return ( - - {peers.map(peer => ( - - - {peer.displayName.charAt(0).toUpperCase()} - - - ))} - - ); -} diff --git a/src/games/sync/deviceIdentity.ts b/src/games/sync/deviceIdentity.ts new file mode 100644 index 00000000..bbb198d1 --- /dev/null +++ b/src/games/sync/deviceIdentity.ts @@ -0,0 +1,94 @@ +const ADJECTIVES = [ + 'Reckless', + 'Bold', + 'Curious', + 'Brave', + 'Eager', + 'Shiny', + 'Sturdy', + 'Molten', + 'Swift', + 'Heavy', + 'Lonely', + 'Mighty', + 'Rusty', + 'Gilded', + 'Electric', + 'Radiant', + 'Stormy', + 'Frozen', + 'Crimson', + 'Verdant', + 'Nuclear', + 'Pulsing', + 'Turbo', + 'Automated', + 'Encased', + 'Pioneer', + 'Industrious', + 'Overclocked', + 'Tactical', + 'Silent', + 'Whirring', + 'Humming', + 'Stoic', + 'Clever', + 'Nimble', + 'Fearless', + 'Daring', + 'Spirited', + 'Resolute', + 'Glittering', +]; + +const CREATURES = [ + 'Lizard Doggo', + 'Spitter', + 'Stinger', + 'Hog', + 'Alpha Hog', + 'Cliff Hog', + 'Crab Hatcher', + 'Plasma Spitter', + 'Nuclear Hog', + 'Gas Stinger', + 'Elite Stinger', + 'Alpha Stinger', + 'Flying Crab', + 'Baby Crab', + 'Fluffy-tailed Hog', +]; + +const DEVICE_NAME_KEY = 'satisfactory-logistics:device-name'; +const SENDER_ID_KEY = 'satisfactory-logistics:sender-id'; + +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +export function generateDeviceName(): string { + return `${pick(ADJECTIVES)} ${pick(CREATURES)}`; +} + +function readOrCreateSessionValue(key: string, factory: () => string): string { + try { + const existing = sessionStorage.getItem(key); + if (existing) return existing; + const created = factory(); + sessionStorage.setItem(key, created); + return created; + } catch { + // sessionStorage may be unavailable (SSR, disabled cookies) — fall back to + // a fresh value for this module lifetime. + return factory(); + } +} + +export const DEVICE_NAME = readOrCreateSessionValue( + DEVICE_NAME_KEY, + generateDeviceName, +); + +export const SENDER_ID = readOrCreateSessionValue(SENDER_ID_KEY, () => + crypto.randomUUID(), +); diff --git a/src/games/sync/peersSlice.ts b/src/games/sync/peersSlice.ts index 01df41cb..517508da 100644 --- a/src/games/sync/peersSlice.ts +++ b/src/games/sync/peersSlice.ts @@ -9,6 +9,7 @@ export interface PeerInfo { userId: string; avatarUrl: string | null; displayName: string; + deviceName: string; factoryId: string | null; } @@ -27,21 +28,29 @@ export const peersSlice = createSlice({ }, }); -export function useOnlinePeers(): PeerInfo[] { +function sortPeers(peers: PeerInfo[]): PeerInfo[] { + // Self first, then alphabetical by displayName, then by deviceName to keep + // multi-device peers of the same user adjacent and stable. + return [...peers].sort((a, b) => { + const aSelf = a.senderId === SENDER_ID ? 0 : 1; + const bSelf = b.senderId === SENDER_ID ? 0 : 1; + if (aSelf !== bSelf) return aSelf - bSelf; + const byName = a.displayName.localeCompare(b.displayName); + if (byName !== 0) return byName; + return a.deviceName.localeCompare(b.deviceName); + }); +} + +export function useAllPeers(): PeerInfo[] { const peers = useStore(s => s.peers.peers); - return useMemo( - () => Object.values(peers).filter(p => p.senderId !== SENDER_ID), - [peers], - ); + return useMemo(() => sortPeers(Object.values(peers)), [peers]); } -export function useFactoryPeers(factoryId: string): PeerInfo[] { +export function useAllFactoryPeers(factoryId: string): PeerInfo[] { const peers = useStore(s => s.peers.peers); return useMemo( () => - Object.values(peers).filter( - p => p.senderId !== SENDER_ID && p.factoryId === factoryId, - ), + sortPeers(Object.values(peers).filter(p => p.factoryId === factoryId)), [peers, factoryId], ); } diff --git a/src/games/sync/realtimeSyncHandlers.ts b/src/games/sync/realtimeSyncHandlers.ts index efab6693..8c3b58ef 100644 --- a/src/games/sync/realtimeSyncHandlers.ts +++ b/src/games/sync/realtimeSyncHandlers.ts @@ -211,15 +211,14 @@ export function computeLeaderAndPeers( for (const p of presences) { if (!p.senderId) continue; senderIds.push(p.senderId); - if (p.senderId !== SENDER_ID) { - peerMap[p.senderId] = { - senderId: p.senderId, - userId: p.userId ?? '', - avatarUrl: p.avatarUrl ?? null, - displayName: p.displayName ?? 'Unknown', - factoryId: p.factoryId ?? null, - }; - } + peerMap[p.senderId] = { + senderId: p.senderId, + userId: p.userId ?? '', + avatarUrl: p.avatarUrl ?? null, + displayName: p.displayName ?? 'Unknown', + deviceName: p.deviceName ?? '', + factoryId: p.factoryId ?? null, + }; } } diff --git a/src/games/sync/realtimeSyncTypes.ts b/src/games/sync/realtimeSyncTypes.ts index 5714d3d7..7780c535 100644 --- a/src/games/sync/realtimeSyncTypes.ts +++ b/src/games/sync/realtimeSyncTypes.ts @@ -2,7 +2,7 @@ import type { Patch } from 'immer'; import type { GameRemoteData } from '@/games/Game'; import type { SerializedGame } from '@/games/store/gameFactoriesActions'; -export const SENDER_ID = crypto.randomUUID(); +export { DEVICE_NAME, SENDER_ID } from './deviceIdentity'; export const PATCH_DEBOUNCE_MS = 150; export const AUTO_SAVE_DEBOUNCE_MS = 40_000; export const DB_FALLBACK_MS = 3_000; @@ -32,6 +32,7 @@ export interface PresencePayload { userId: string; avatarUrl: string | null; displayName: string; + deviceName: string; factoryId: string | null; } diff --git a/src/games/sync/ui/DeviceIdenticon.tsx b/src/games/sync/ui/DeviceIdenticon.tsx new file mode 100644 index 00000000..772ed993 --- /dev/null +++ b/src/games/sync/ui/DeviceIdenticon.tsx @@ -0,0 +1,35 @@ +import { minidenticon } from 'minidenticons'; +import { useMemo } from 'react'; + +export interface DeviceIdenticonProps { + seed: string; + size?: number; + saturation?: number; + lightness?: number; + title?: string; +} + +export function DeviceIdenticon({ + seed, + size = 12, + saturation = 85, + lightness = 55, + title, +}: DeviceIdenticonProps) { + const svg = useMemo( + () => minidenticon(seed, saturation, lightness), + [seed, saturation, lightness], + ); + const dataUrl = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; + + return ( + {title + ); +} diff --git a/src/games/sync/ui/FactoryPeers.tsx b/src/games/sync/ui/FactoryPeers.tsx new file mode 100644 index 00000000..cdf4d2a8 --- /dev/null +++ b/src/games/sync/ui/FactoryPeers.tsx @@ -0,0 +1,42 @@ +import { Box } from '@mantine/core'; +import { useMemo } from 'react'; +import { useAllFactoryPeers } from '../peersSlice'; +import { PeerAvatar } from './PeerAvatar'; + +export interface FactoryPeersProps { + factoryId: string; +} + +export function FactoryPeers({ factoryId }: FactoryPeersProps) { + const peers = useAllFactoryPeers(factoryId); + + 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 (peers.length === 0) return null; + + return ( + + {peers.map((peer, idx) => ( + + 1} + /> + + ))} + + ); +} diff --git a/src/games/sync/ui/OnlinePeers.tsx b/src/games/sync/ui/OnlinePeers.tsx new file mode 100644 index 00000000..269a5ac4 --- /dev/null +++ b/src/games/sync/ui/OnlinePeers.tsx @@ -0,0 +1,142 @@ +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/PeerAvatar.tsx b/src/games/sync/ui/PeerAvatar.tsx new file mode 100644 index 00000000..70426f84 --- /dev/null +++ b/src/games/sync/ui/PeerAvatar.tsx @@ -0,0 +1,81 @@ +import { Avatar, Box, Tooltip } from '@mantine/core'; +import type { PeerInfo } from '../peersSlice'; +import { SENDER_ID } from '../realtimeSyncTypes'; +import { DeviceIdenticon } from './DeviceIdenticon'; + +export interface PeerAvatarProps { + peer: PeerInfo; + showDeviceBadge?: boolean; + size?: number; + withTooltip?: boolean; +} + +export function PeerAvatar({ + peer, + showDeviceBadge = false, + size = 28, + withTooltip = true, +}: PeerAvatarProps) { + const isSelf = peer.senderId === SENDER_ID; + const tooltipLabel = [ + peer.displayName + (isSelf ? ' (you)' : ''), + peer.deviceName, + ] + .filter(Boolean) + .join(' · '); + + const badgeSize = Math.max(16, Math.round(size * 0.7)); + + const avatar = ( + + {peer.displayName.charAt(0).toUpperCase()} + + ); + + const content = + showDeviceBadge && peer.deviceName ? ( + + {avatar} + + + + + ) : ( + avatar + ); + + if (!withTooltip) return content; + + return ( + + {content} + + ); +} diff --git a/src/games/sync/ui/RealtimeSyncIndicator.tsx b/src/games/sync/ui/RealtimeSyncIndicator.tsx new file mode 100644 index 00000000..5a0960b1 --- /dev/null +++ b/src/games/sync/ui/RealtimeSyncIndicator.tsx @@ -0,0 +1,66 @@ +import { Box, Group, Tooltip } from '@mantine/core'; +import { IconBroadcast, IconBroadcastOff } from '@tabler/icons-react'; +import { useStore } from '@/core/zustand'; +import { useAllPeers } from '../peersSlice'; +import { OnlinePeers } from './OnlinePeers'; + +export function RealtimeSyncIndicator() { + const isConnected = useStore(s => s.gameSave.isRealtimeSyncConnected); + const peers = useAllPeers(); + + if (!isConnected && peers.length === 0) return null; + + return ( + + + + {isConnected ? ( + <> + + + + ) : ( + + )} + + + {peers.length > 0 && } + + ); +} diff --git a/src/games/sync/useRealtimeGameSync.ts b/src/games/sync/useRealtimeGameSync.ts index b836eee0..fed8b020 100644 --- a/src/games/sync/useRealtimeGameSync.ts +++ b/src/games/sync/useRealtimeGameSync.ts @@ -23,6 +23,7 @@ import { BROADCAST_EVENT, BROADCAST_FULL_REQUEST, BROADCAST_FULL_RESPONSE, + DEVICE_NAME, type FullStateRequestPayload, type FullStateResponsePayload, isBroadcastSuppressed, @@ -65,6 +66,7 @@ export function useRealtimeGameSync() { avatarUrl: user.user_metadata?.avatar_url ?? null, displayName: user.user_metadata?.full_name ?? user.user_metadata?.name ?? 'Unknown', + deviceName: DEVICE_NAME, factoryId, }; channelRef.current.track(payload); @@ -215,6 +217,7 @@ export function useRealtimeGameSync() { user.user_metadata?.full_name ?? user.user_metadata?.name ?? 'Unknown', + deviceName: DEVICE_NAME, factoryId: factoryIdRef.current, } satisfies PresencePayload); doRequestFullState(); diff --git a/src/layout/Header.tsx b/src/layout/Header.tsx index 6c8df291..0eca3194 100644 --- a/src/layout/Header.tsx +++ b/src/layout/Header.tsx @@ -14,7 +14,7 @@ import { UserMenu } from '@/auth/UserMenu'; import { useStore } from '@/core/zustand'; import { GameMenu } from '@/games/menu/GameMenu'; import { GameSettingsModal } from '@/games/settings/GameSettingsModal'; -import { OnlinePeers } from '@/games/sync/OnlinePeers'; +import { RealtimeSyncIndicator } from '@/games/sync/ui/RealtimeSyncIndicator'; import { NotesPanelTrigger } from '@/notes/NotesPanelTrigger'; import { TutorialMenu } from '@/tutorial/TutorialMenu'; import classes from './Header.module.css'; @@ -68,17 +68,19 @@ export function Header() { )} - - Satisfactory Logistics Planner - + + + Satisfactory Logistics Planner + + + {hasSelectedGame && } -