Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4946,6 +4946,58 @@ export async function runWebSmoke(): Promise<void> {
if (itemLabel('') !== '?') return fail('an empty item id has no label')
}

// ---- pinning the public map to one world (#137) ----
{
const before = siteMod.getSiteConfig().map
try {
siteMod.setSiteConfig({ map: { ...before, enabled: true, serverId: id, fixedDim: 'nether' } })
const cfg = siteMod.publicMapConfig()
if (cfg?.fixedDim !== 'nether') return fail('the pinned world did not survive the config')

// A dimension name becomes a path segment when the tiles are read, so
// anything that is not a plain name is refused at the boundary rather
// than trusted at the point of use.
for (const bad of ['../../etc', 'a/b', '..', '.', 'x'.repeat(80), 'a b']) {
siteMod.setSiteConfig({ map: { ...before, enabled: true, serverId: id, fixedDim: bad } })
const got = siteMod.getSiteConfig().map.fixedDim
if (got !== '') return fail('a bad pinned world was accepted: ' + JSON.stringify(bad) + ' -> ' + got)
}

// Pinned, the feed answers with that world whatever the caller asks
// for, and offers exactly one — a switcher that cannot change the
// answer is not a switcher.
siteMod.setSiteConfig({ map: { ...before, enabled: true, serverId: id, fixedDim: 'end' } })
const pr2 = await sget('/api/public/map?dim=overworld')
if (pr2.status !== 200) return fail('the pinned public map expected 200, got ' + pr2.status)
const feed = (await pr2.json()) as { dimension: string; dimensions: string[]; pinned: boolean }
if (feed.dimension !== 'end') return fail('the pin did not override the query: ' + feed.dimension)
if (!feed.pinned) return fail('the feed did not say it was pinned')
if (feed.dimensions.length !== 1 || feed.dimensions[0] !== 'end') {
return fail('a pinned feed offered a switcher: ' + JSON.stringify(feed.dimensions))
}

// A custom world keeps its case: the name becomes a folder name, and
// lower-casing it finds `myworld/region` for a folder called
// `MyWorld` — which works on Windows and does not on Linux.
if (normalizeDimension('MyWorld') !== 'MyWorld') {
return fail('a custom world name was case-folded: ' + normalizeDimension('MyWorld'))
}
// ...while the three real dimensions still canonicalise.
if (normalizeDimension('THE_END') !== 'end') return fail('THE_END did not canonicalise')
if (normalizeDimension('minecraft:the_nether') !== 'nether') return fail('the nether id did not canonicalise')
if (normalizeDimension('') !== 'overworld') return fail('an empty dimension is not the overworld')

// Unpinned, it follows the query again.
siteMod.setSiteConfig({ map: { ...before, enabled: true, serverId: id, fixedDim: '' } })
const pr3 = await sget('/api/public/map?dim=nether')
const feed3 = (await pr3.json()) as { dimension: string; pinned: boolean }
if (feed3.dimension !== 'nether') return fail('an unpinned map ignored the query')
if (feed3.pinned) return fail('an unpinned map claimed to be pinned')
} finally {
siteMod.setSiteConfig({ map: before })
}
}

console.log('WEB-SMOKE: profile visibility OK (' + checked + ' checks, omitted not hidden, per-field toggles)')

// ---- the refresh budget (#117) ----
Expand Down
43 changes: 38 additions & 5 deletions src/main/web/publicSiteHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ img{max-width:100%;display:block}
/* header */
header{position:sticky;top:0;z-index:40;backdrop-filter:blur(16px) saturate(140%);background:color-mix(in srgb,var(--bg) 76%,transparent);border-bottom:1px solid var(--line)}
.nav{display:flex;align-items:center;gap:8px;height:70px}
.brand{display:flex;align-items:center;gap:11px;font-weight:850;font-size:19.5px;letter-spacing:-.3px;margin-right:18px}
/* min-width:0 so a long server name shortens instead of shoving the nav links
and the account chip out of the row (#137). */
.brand{display:flex;align-items:center;gap:11px;font-weight:850;font-size:19.5px;letter-spacing:-.3px;
margin-right:18px;min-width:0;flex-shrink:0}
.brand span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.navend{display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:auto}
.brand img{width:36px;height:36px;border-radius:10px;object-fit:cover;box-shadow:0 0 0 1px var(--line),0 6px 18px -6px var(--glow)}
.brand svg{width:36px;height:36px;filter:drop-shadow(0 4px 14px var(--glow))}
.navlinks{display:flex;gap:2px;flex:1}
Expand Down Expand Up @@ -257,17 +262,45 @@ body.classic .hero .desc{margin-left:0}
body.classic .hero h1{letter-spacing:-1.2px}
@media(max-width:820px){.stat{flex:1;min-width:130px}}
@media(max-width:720px){.nav{height:auto;flex-wrap:wrap;padding:10px 0;gap:6px}
.navlinks{order:3;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}
/* Brand and the account controls share the top row; the links get their own
full-width, scrollable strip underneath. */
.brand{margin-right:0;font-size:17px}
.navend{order:2}
.navlinks{order:3;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch;
flex:0 0 100%;padding-bottom:2px;scrollbar-width:none}
.navlinks::-webkit-scrollbar{display:none}
.navlink{white-space:nowrap;padding:8px 11px;font-size:14px}
.hero{padding:64px 0 48px}.hero.image{padding:88px 0 62px}
.section{padding:44px 0}.stats{gap:10px}.stat{padding:13px 16px;min-width:0}
.stat b{font-size:22px}.foot{flex-direction:column;text-align:center}}
.stat b{font-size:22px}.foot{flex-direction:column;text-align:center}
/* The profile stacks rather than running off the side. */
.phero{gap:14px}.phero .big{width:72px;height:72px}
.pid h2{font-size:25px}
.pmeta>div,.pcoords>div{flex:1 1 128px;min-width:0}
.inv{grid-template-columns:repeat(auto-fill,minmax(72px,1fr))}
.ihead{gap:8px}
.mp-bar{gap:6px}.mp-bar select,.mp-bar button{font-size:12.5px;padding:7px 9px}}
@media(max-width:460px){
/* At phone width the brand name is the first thing worth dropping — the logo
still says which server this is, and the account chip must not be pushed
off the screen for a word. */
.brand span:last-child{display:none}
.btn{padding:9px 14px;font-size:13.5px}
.wrap{padding:0 14px}
.hero h1{font-size:clamp(28px,9vw,44px)}
.inv{grid-template-columns:repeat(auto-fill,minmax(64px,1fr))}
.whoami span{max-width:88px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
</style></head>
<body>
<header><div class="wrap nav">
<a class="brand" href="#/"><span id="brandLogo"></span><span id="brandName">Server</span></a>
<nav class="navlinks" id="navlinks"></nav>
<select class="lang" id="langSel" onchange="setLang(this.value)"></select>
<span id="accBtn"></span>
<!-- The right-hand controls are one group, so a narrow window moves them
together instead of dealing them out between the nav links (#137). -->
<div class="navend">
<select class="lang" id="langSel" onchange="setLang(this.value)"></select>
<span id="accBtn"></span>
</div>
</div></header>

<main id="app"></main>
Expand Down
12 changes: 10 additions & 2 deletions src/main/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -621,12 +621,20 @@ async function handlePublic(
// `path` here is already stripped of the query, so the dimension is read
// from the raw url. A missing or unparseable one is the overworld.
const q = new URL(req.url ?? '/', 'http://localhost').searchParams
const dim = normalizeDimension(q.get('dim') ?? 'overworld')
// A pinned world overrides what the visitor asks for and what the players
// are standing in: the point of pinning is that the site always shows the
// same place, with nobody online and whatever the query says (#137).
const dim = cfg.fixedDim
? normalizeDimension(cfg.fixedDim)
: normalizeDimension(q.get('dim') ?? 'overworld')
const players = redactPlayers(all.filter((p) => p.dim === dim), cfg)
return sendJson(res, 200, {
bridge: rt ? bridgeFresh(rt.bridge, now) : false,
dimension: dim,
dimensions: [...new Set(all.map((p) => p.dim))].sort(),
// One entry when pinned, so the page has nothing to offer a switcher —
// a control that cannot change the answer is not a control.
dimensions: cfg.fixedDim ? [dim] : [...new Set(all.map((p) => p.dim))].sort(),
pinned: !!cfg.fixedDim,
players,
// Bounds from the ROUNDED positions. Deriving them from the exact ones
// would publish a tighter box than the dots inside it, and the corner of
Expand Down
6 changes: 6 additions & 0 deletions src/main/web/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ export function setSiteConfig(patch: Partial<SiteConfig>): SiteConfig {
if (m.world !== undefined) s.map.world = !!m.world
if (m.structures !== undefined) s.map.structures = !!m.structures
if (m.loadAhead !== undefined) s.map.loadAhead = !!m.loadAhead
if (m.fixedDim !== undefined) {
// A dimension name becomes a PATH SEGMENT when the tiles are read, so it
// is restricted here rather than trusted at the point of use.
const d = String(m.fixedDim ?? '').trim()
s.map.fixedDim = /^[A-Za-z0-9_.-]{0,64}$/.test(d) && d !== '.' && d !== '..' ? d : ''
}
}
if (patch.profile) {
// Field by field and coerced, for the reason above: "false" is truthy, and
Expand Down
103 changes: 75 additions & 28 deletions src/renderer/src/components/PlayerAvatar.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,35 @@
import { useState, useEffect } from 'react'
import { useEffect, useState } from 'react'
import { avatarUrl } from '@shared/profile'

/**
* Minecraft head avatar from an online, always-updatable source (minotar.net).
* Works with a UUID (premium/online-mode) or a username (offline). Falls back to
* a coloured initial box if the network image can't load.
* A player's head.
*
* Three things were making these flash empty and re-fetch on every render
* (#137):
*
* - the requested size was baked into the URL, so the 46px list avatar and the
* 72px detail avatar were two different images of the same player, fetched
* separately and cached separately;
* - the uuid was preferred over the name, and on an offline-mode server that
* uuid is the derived one no skin service has ever seen — the same trap as
* the map heads in #116;
* - a failure was remembered only in component state, so every remount asked
* for the same missing image again and showed a gap while it failed.
*
* One size is requested and CSS scales it, the name decides, and the outcome is
* remembered for the session.
*/

/** Requested once at this size; anything larger is upscaled by CSS. */
const FETCH_PX = 64

/**
* Module-level, deliberately: a head that failed once will fail again, and the
* point is not to ask a second time. Keyed by name, which is what the URL is
* keyed by.
*/
const outcome = new Map<string, 'ok' | 'fail'>()

export function PlayerAvatar({
uuid,
name,
Expand All @@ -14,31 +39,53 @@ export function PlayerAvatar({
name: string
size?: number
}): JSX.Element {
const key = uuid && uuid.replace(/-/g, '').length >= 32 ? uuid : name
const url = `https://minotar.net/helm/${encodeURIComponent(key)}/${size * 2}`
const [err, setErr] = useState(false)
useEffect(() => setErr(false), [key])
// `uuid` is still accepted so callers do not have to change, and deliberately
// unused: see the note above.
void uuid
const url = avatarUrl(name, FETCH_PX)
const [state, setState] = useState<'ok' | 'fail' | 'loading'>(() => outcome.get(name) ?? 'loading')

useEffect(() => setState(outcome.get(name) ?? 'loading'), [name])

// A placeholder while it comes and if it never does, rather than a hole. The
// initial when there is a name to take one from, a question mark when there
// is not.
const placeholder = (
<div
className="pavatar-fallback"
style={{ width: size, height: size, fontSize: Math.round(size * 0.42) }}
aria-label={name}
>
{(name[0] || '?').toUpperCase()}
</div>
)
if (!name || state === 'fail') return placeholder

if (err || !key) {
return (
<div
className="pavatar-fallback"
style={{ width: size, height: size, fontSize: Math.round(size * 0.42) }}
>
{(name[0] || '?').toUpperCase()}
</div>
)
}
return (
<img
className="pavatar"
src={url}
width={size}
height={size}
alt={name}
loading="lazy"
onError={() => setErr(true)}
style={{ imageRendering: 'pixelated' }}
/>
<>
{state === 'loading' && placeholder}
<img
className="pavatar"
src={url}
width={size}
height={size}
alt={name}
onLoad={() => {
outcome.set(name, 'ok')
setState('ok')
}}
onError={() => {
outcome.set(name, 'fail')
setState('fail')
}}
style={{
imageRendering: 'pixelated',
// Kept in the tree while loading so the browser starts the request,
// but out of the layout so it does not sit next to its own
// placeholder.
display: state === 'ok' ? undefined : 'none'
}}
/>
</>
)
}
4 changes: 4 additions & 0 deletions src/renderer/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,10 @@ export default {
mapNames: 'Show names',
mapHeads: 'Draw skin heads',
mapWorld: 'Show the terrain',
mapFixed: 'World shown',
mapFollowPlayers: 'Follow the players',
mapFixedHint:
'Pinning a world makes the map worth opening on a quiet server: it renders with nobody online, and visitors get no dimension switcher because you have already chosen.',
mapStructures: 'Show villages and dungeons',
mapLoadAhead: 'Load ahead of the view',
mapLoadHint:
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/locales/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,10 @@ const tr: typeof en = {
mapNames: 'İsimleri göster',
mapHeads: 'Oyuncu kafalarını çiz',
mapWorld: 'Araziyi göster',
mapFixed: 'Gösterilecek dünya',
mapFollowPlayers: 'Oyuncuları takip et',
mapFixedHint:
'Bir dünyayı sabitlemek, sessiz bir sunucuda haritayı açmaya değer kılar: kimse çevrimiçi değilken de çizilir ve ziyaretçiye boyut seçici gösterilmez, çünkü seçimi siz yaptınız.',
mapStructures: 'Köyleri ve zindanları göster',
mapLoadAhead: 'Görünümün ötesini önceden yükle',
mapLoadHint:
Expand Down
41 changes: 41 additions & 0 deletions src/renderer/src/views/SiteView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,28 @@ export function SiteView(): JSX.Element {
setCfg(await window.msms.getSiteConfig())
setWeb(await window.msms.getWebStatus())
}
// Worlds that actually exist on the map server, so the pin is a choice from
// what is there rather than a name typed from memory.
const [mapWorlds, setMapWorlds] = useState<string[]>(['overworld', 'nether', 'end'])

useEffect(() => {
void refresh()
}, [])

useEffect(() => {
const sid = cfg?.map.serverId
if (!sid) return
window.msms
.listWorlds(sid)
.then((ws) => {
// The three dimensions of the active world are always offered; a
// separate world folder is offered by its own name.
const extra = ws.map((w) => w.name).filter((n) => n && !['overworld', 'nether', 'end'].includes(n))
setMapWorlds(['overworld', 'nether', 'end', ...extra])
})
.catch(() => setMapWorlds(['overworld', 'nether', 'end']))
}, [cfg?.map.serverId])

if (!cfg) return <div className="center-fill" />

const siteBase = (): string =>
Expand Down Expand Up @@ -218,6 +236,29 @@ export function SiteView(): JSX.Element {
{t('site.mapLoadAhead')}
</label>
</div>
{/* Pinning is what makes an empty server's map worth opening: it
renders with nobody online, and the visitor gets no switcher
because the operator already chose (#137). */}
<div className="row wrap" style={{ gap: 12, alignItems: 'flex-end', marginTop: 12 }}>
<div style={{ minWidth: 220 }}>
<div className="dim" style={{ fontSize: 12 }}>
{t('site.mapFixed')}
</div>
<select
className="select"
value={cfg.map.fixedDim}
onChange={(e) => patchMap({ fixedDim: e.target.value })}
>
<option value="">{t('site.mapFollowPlayers')}</option>
{mapWorlds.map((w) => (
<option key={w} value={w}>
{w}
</option>
))}
</select>
</div>
</div>
<p className="hint">{t('site.mapFixedHint')}</p>
<p className="hint">{t('site.mapHint')}</p>
<p className="hint">{t('site.mapLoadHint')}</p>

Expand Down
Loading
Loading