Skip to content

Commit

Permalink
feat(ui): Improve server list UI
Browse files Browse the repository at this point in the history
- Implement concurrent server status fetching with rate limiting
- Show offline status for servers that cannot be reached
  • Loading branch information
zardoy committed Jan 31, 2025
1 parent 28f0546 commit 14b7cb0
Show file tree
Hide file tree
Showing 4 changed files with 91 additions and 26 deletions.
5 changes: 3 additions & 2 deletions src/react/AddServerOrConnect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Screen from './Screen'
import Input from './Input'
import Button from './Button'
import SelectGameVersion from './SelectGameVersion'
import { useIsSmallWidth } from './simpleHooks'
import { useIsSmallWidth, usePassesWindowDimensions } from './simpleHooks'

export interface BaseServerInfo {
ip: string
Expand Down Expand Up @@ -32,6 +32,7 @@ interface Props {
const ELEMENTS_WIDTH = 190

export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQs, onQsConnect, placeholders, accounts, versions, allowAutoConnect }: Props) => {
const isSmallHeight = !usePassesWindowDimensions(null, 350)
const qsParams = parseQs ? new URLSearchParams(window.location.search) : undefined
const qsParamName = qsParams?.get('name')
const qsParamIp = qsParams?.get('ip')
Expand Down Expand Up @@ -101,7 +102,7 @@ export default ({ onBack, onConfirm, title = 'Add a Server', initialData, parseQ
</>}
<InputWithLabel required label="Server IP" value={serverIp} disabled={lockConnect && qsIpParts?.[0] !== null} onChange={({ target: { value } }) => setServerIp(value)} />
<InputWithLabel label="Server Port" value={serverPort} disabled={lockConnect && qsIpParts?.[1] !== null} onChange={({ target: { value } }) => setServerPort(value)} placeholder='25565' />
<div style={{ gridColumn: smallWidth ? '' : 'span 2' }}>Overrides:</div>
{isSmallHeight ? <div style={{ gridColumn: 'span 2', marginTop: 10, }} /> : <div style={{ gridColumn: smallWidth ? '' : 'span 2' }}>Overrides:</div>}
<div style={{
display: 'flex',
flexDirection: 'column',
Expand Down
87 changes: 65 additions & 22 deletions src/react/ServersListProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type AdditionalDisplayData = {
formattedText: string
textNameRight: string
icon?: string
offline?: boolean
}

export interface AuthenticatedAccount {
Expand Down Expand Up @@ -138,6 +139,9 @@ export const updateAuthenticatedAccountData = (callback: (data: AuthenticatedAcc
// todo move to base
const normalizeIp = (ip: string) => ip.replace(/https?:\/\//, '').replace(/\/(:|$)/, '')

const FETCH_DELAY = 100 // ms between each request
const MAX_CONCURRENT_REQUESTS = 10

const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersList?: string[] }) => {
const [proxies, setProxies] = useState<readonly string[]>(localStorage['proxies'] ? JSON.parse(localStorage['proxies']) : getInitialProxies())
const [selectedProxy, setSelectedProxy] = useState(proxyQs ?? localStorage.getItem('selectedProxy') ?? proxies?.[0] ?? '')
Expand Down Expand Up @@ -198,30 +202,69 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL

useUtilsEffect(({ signal }) => {
const update = async () => {
for (const server of serversListSorted) {
const isInLocalNetwork = server.ip.startsWith('192.168.') || server.ip.startsWith('10.') || server.ip.startsWith('172.') || server.ip.startsWith('127.') || server.ip.startsWith('localhost')
if (isInLocalNetwork || signal.aborted) continue
// eslint-disable-next-line no-await-in-loop
await fetch(`https://api.mcstatus.io/v2/status/java/${server.ip}`, {
// TODO: bounty for this who fix it
// signal
}).then(async r => r.json()).then((data: ServerResponse) => {
const versionClean = data.version?.name_raw.replace(/^[^\d.]+/, '')
if (!versionClean) return
setAdditionalData(old => {
return ({
...old,
[server.ip]: {
formattedText: data.motd?.raw ?? '',
textNameRight: `${versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}`,
icon: data.icon,
}
})
})
const queue = serversListSorted
.map(server => {
const isInLocalNetwork = server.ip.startsWith('192.168.') ||
server.ip.startsWith('10.') ||
server.ip.startsWith('172.') ||
server.ip.startsWith('127.') ||
server.ip.startsWith('localhost') ||
server.ip.startsWith(':')

const VALID_IP_OR_DOMAIN = server.ip.includes('.')
if (isInLocalNetwork || signal.aborted || !VALID_IP_OR_DOMAIN) return null

return server
})
.filter(x => x !== null)

const activeRequests = new Set<Promise<void>>()

let lastRequestStart = 0
for (const server of queue) {
// Wait if at concurrency limit
if (activeRequests.size >= MAX_CONCURRENT_REQUESTS) {
// eslint-disable-next-line no-await-in-loop
await Promise.race(activeRequests)
}

// Create and track new request
// eslint-disable-next-line @typescript-eslint/no-loop-func
const request = new Promise<void>(resolve => {
setTimeout(async () => {
try {
lastRequestStart = Date.now()
if (signal.aborted) return
const response = await fetch(`https://api.mcstatus.io/v2/status/java/${server.ip}`, { signal })
const data: ServerResponse = await response.json()
const versionClean = data.version?.name_raw.replace(/^[^\d.]+/, '')

setAdditionalData(old => ({
...old,
[server.ip]: {
formattedText: data.motd?.raw ?? '',
textNameRight: data.online ?
`${versionClean} ${data.players?.online ?? '??'}/${data.players?.max ?? '??'}` :
'',
icon: data.icon,
offline: !data.online
}
}))
} finally {
activeRequests.delete(request)
resolve()
}
}, lastRequestStart ? Math.max(0, FETCH_DELAY - (Date.now() - lastRequestStart)) : 0)
})

activeRequests.add(request)
}

// Wait for remaining requests
await Promise.all(activeRequests)
}
void update().catch((err) => {})

void update()
}, [serversListSorted])

const isEditScreenModal = useIsModalActive('editServer')
Expand Down Expand Up @@ -394,10 +437,10 @@ const Inner = ({ hidden, customServersList }: { hidden?: boolean, customServersL
name: server.index.toString(),
title: server.name || server.ip,
detail: (server.versionOverride ?? '') + ' ' + (server.usernameOverride ?? ''),
// lastPlayed: server.lastJoined,
formattedTextOverride: additional?.formattedText,
worldNameRight: additional?.textNameRight ?? '',
iconSrc: additional?.icon,
offline: additional?.offline
}
})}
initialProxies={{
Expand Down
13 changes: 11 additions & 2 deletions src/react/Singleplayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import Button from './Button'
import Tabs from './Tabs'
import MessageFormattedString from './MessageFormattedString'
import { useIsSmallWidth } from './simpleHooks'
import PixelartIcon from './PixelartIcon'

export interface WorldProps {
name: string
Expand All @@ -26,9 +27,10 @@ export interface WorldProps {
onFocus?: (name: string) => void
onInteraction?(interaction: 'enter' | 'space')
elemRef?: React.Ref<HTMLDivElement>
offline?: boolean
}

const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef }: WorldProps & { ref?: React.Ref<HTMLDivElement> }) => {
const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus, onInteraction, iconSrc, formattedTextOverride, worldNameRight, elemRef, offline }: WorldProps & { ref?: React.Ref<HTMLDivElement> }) => {
const timeRelativeFormatted = useMemo(() => {
if (!lastPlayed) return ''
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
Expand Down Expand Up @@ -60,7 +62,14 @@ const World = ({ name, isFocused, title, lastPlayed, size, detail = '', onFocus,
<div className={styles.world_info}>
<div className={styles.world_title}>
<div>{title}</div>
<div className={styles.world_title_right}>{worldNameRight}</div>
<div className={styles.world_title_right}>
{offline ? (
<span style={{ color: 'red', display: 'flex', alignItems: 'center', gap: 4 }}>
<PixelartIcon iconName="signal-off" width={12} />
Offline
</span>
) : worldNameRight}
</div>
</div>
{formattedTextOverride ? <div className={styles.world_info_formatted}>
<MessageFormattedString message={formattedTextOverride} />
Expand Down
12 changes: 12 additions & 0 deletions src/react/simpleHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ export const useIsSmallWidth = () => {
return useMedia(SMALL_SCREEN_MEDIA.replace('@media ', ''))
}

export const usePassesWindowDimensions = (minWidth: number | null = null, minHeight: number | null = null) => {
let media = '('
if (minWidth !== null) {
media += `min-width: ${minWidth}px, `
}
if (minHeight !== null) {
media += `min-height: ${minHeight}px, `
}
media += ')'
return useMedia(media)
}

export const useCopyKeybinding = (getCopyText: () => string | undefined) => {
useUtilsEffect(({ signal }) => {
addEventListener('keydown', (e) => {
Expand Down

0 comments on commit 14b7cb0

Please sign in to comment.