fix(rust): nothing names who is online by default
The org lead's rule, settled 2026-09-22: who is online is always the
narrowest audience - staff - unless an operator deliberately widens it,
and a count is fine where a list of names is not.
The public site broke that in three places since phase 4. The Online
tab named every player, the feed carried joins, respawns, deaths, chat
and tallies, and the leaderboard's lastSeen - refreshed every minute by
a gather tally - said who was on as plainly as either. All three now
sit behind one setting:
* PRESENCE_KINDS, a subset of the public allowlist, gated per request.
Below the audience the feed keeps the server's own story (wipe, start,
shutdown) and says presenceHidden rather than looking quiet.
* the Online route answers { players: [], hidden, count, audience } -
same shape, so an older client renders empty rather than breaking.
* rungs staff / signed_in / public, fleet-wide default in a new
rust_settings table with an optional per-server override on
rust_servers; an unknown stored word narrows to staff.
* the viewer's standing is RE-READ from the users row (ctx.users.getById),
not taken from the token, so a demotion or a ban applies on the next
request. Walked: a moderator demoted mid-session lost the roll call on
the same cookie.
* per-viewer answers are Cache-Control: private, no-store.
* GET/PUT /admin/rust/visibility (requireRole admin) and an admin page,
Rust visibility; every save is one activity-log row.
The browser walk also found every empty state in this module rendering
as a blank box. Core's EmptyState renders children only; this module
passed title/message (the shape the Integration Kit template teaches)
and React dropped both without a word. Fixed module-side with a small
Empty wrapper - nothing core or module-uo renders changes - and a client
test that refuses a titled EmptyState or a PageHeader subtitle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
26
client/src/components/Empty.jsx
Normal file
26
client/src/components/Empty.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
// ── An empty state with a heading and a sentence ──────────────────────────
|
||||
//
|
||||
// Core's `EmptyState` renders its CHILDREN and nothing else. This module passed
|
||||
// it `title` and `message` from phase 4 onwards — the shape the Integration Kit's
|
||||
// template teaches — and React drops an unknown prop without a word, so every
|
||||
// empty panel in the module rendered as a blank box: "Nobody is on", "No scores
|
||||
// yet", "No servers yet", all of them. Found by the presence fix's browser walk,
|
||||
// when the "12 players online" it depended on came out as nothing.
|
||||
//
|
||||
// Fixed here rather than in core: core's component is shared by every module,
|
||||
// and a module-side wrapper changes nothing anybody else renders. The client
|
||||
// suite (`test/uiKitProps.test.js`) refuses a titled EmptyState so the mistake
|
||||
// cannot come back.
|
||||
|
||||
import { EmptyState } from '../core.js'
|
||||
|
||||
export default function Empty({ title, message }) {
|
||||
return (
|
||||
<EmptyState>
|
||||
{title && (
|
||||
<strong style={{ display: 'block', color: 'var(--head)', marginBottom: message ? 6 : 0 }}>{title}</strong>
|
||||
)}
|
||||
{message && <span>{message}</span>}
|
||||
</EmptyState>
|
||||
)
|
||||
}
|
||||
@@ -11,11 +11,13 @@
|
||||
// see the comment at the top of that file for why core's `useAsync` cannot do
|
||||
// this job.
|
||||
|
||||
import { EmptyState, ErrorState, Loading } from '../core.js'
|
||||
import { ErrorState, Loading } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { describe, FILTERS, kindsFor } from '../lib/feed.js'
|
||||
import { ago, clock } from '../lib/format.js'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import api from '../api.js'
|
||||
import { hiddenMessage } from './Online.jsx'
|
||||
|
||||
const TONE = {
|
||||
kill: 'var(--accent-bright)',
|
||||
@@ -79,10 +81,24 @@ export default function Feed({ serverId, wipeId, filter, onFilter }) {
|
||||
off" must not blank itself the first time a request does. */}
|
||||
{error && !data && <ErrorState error={error} />}
|
||||
|
||||
{/* Below the operator's presence audience the server withholds every item
|
||||
that names a player who was on — the killfeed, chat, joins — and keeps
|
||||
only the server's own story. Said once, above the rows, so a thin feed
|
||||
reads as withheld rather than as a quiet server. */}
|
||||
{data && data.presenceHidden && (
|
||||
<p className="sans" style={{ color: 'var(--dim)', fontSize: '0.8rem', marginTop: 0 }}>
|
||||
Joins, deaths and chat are not shown. {hiddenMessage(data.presenceAudience, 'what players did')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && events.length === 0 && (
|
||||
<EmptyState
|
||||
<Empty
|
||||
title="Nothing here yet"
|
||||
message="Nothing this server has reported matches. A server that has just been added has no history until it says something."
|
||||
message={
|
||||
data.presenceHidden
|
||||
? 'Nothing this server has reported about itself matches.'
|
||||
: 'Nothing this server has reported matches. A server that has just been added has no history until it says something.'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
// than one that is four minutes old, and the page has a `Refresh` on the tab
|
||||
// strip for anybody who disagrees.
|
||||
|
||||
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
|
||||
import { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { ago, count, duration, shortId } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
@@ -32,13 +33,15 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||
)
|
||||
|
||||
const rows = data ? data.leaderboard : []
|
||||
// Present on every row or on none — the server decides per request.
|
||||
const showLastSeen = rows.some((row) => 'lastSeen' in row)
|
||||
|
||||
if (loading) return <Loading />
|
||||
if (error) return <ErrorState error={error} />
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
<Empty
|
||||
title="No scores yet"
|
||||
message={
|
||||
wipeId
|
||||
@@ -80,7 +83,13 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
|
||||
{/* The server withholds `lastSeen` below the operator's presence
|
||||
audience — a gather tally refreshes it every minute somebody plays,
|
||||
so it would name who is online. The column goes with it rather
|
||||
than rendering a row of dashes that look like "never". */}
|
||||
{showLastSeen && (
|
||||
<th style={{ ...cell, textAlign: 'right', textTransform: 'uppercase' }}>Last seen</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -98,7 +107,9 @@ export default function Leaderboard({ serverId, wipeId, sort, onSort }) {
|
||||
{column.value(row)}
|
||||
</td>
|
||||
))}
|
||||
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
|
||||
{showLastSeen && (
|
||||
<td style={{ ...cell, textAlign: 'right', color: 'var(--dim)' }}>{ago(row.lastSeen)}</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
// It polls with the feed, because "who is on" is the one thing on this page that
|
||||
// is a live question.
|
||||
|
||||
import { EmptyState, ErrorState, Loading } from '../core.js'
|
||||
import { ErrorState, Loading } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { duration, shortId } from '../lib/format.js'
|
||||
import usePolled from '../hooks/usePolled.js'
|
||||
import api from '../api.js'
|
||||
@@ -25,9 +26,23 @@ export default function Online({ serverId, online }) {
|
||||
if (loading) return <Loading />
|
||||
if (error && !data) return <ErrorState error={error} />
|
||||
|
||||
// Nothing names who is online by default (the org lead's rule). Below the
|
||||
// operator's audience the server answers a count and no names, and the page
|
||||
// says so — an empty list here would read as "nobody is on", which is a
|
||||
// different claim and a false one.
|
||||
if (data && data.hidden) {
|
||||
const count = Number(data.count) || 0
|
||||
return (
|
||||
<Empty
|
||||
title={online ? `${count.toLocaleString()} ${count === 1 ? 'player' : 'players'} online` : 'The server is offline'}
|
||||
message={hiddenMessage(data.audience)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (players.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
<Empty
|
||||
title={online ? 'Nobody is on' : 'The server is offline'}
|
||||
message={
|
||||
online
|
||||
@@ -92,3 +107,16 @@ function sessionSoFar(connectedAt) {
|
||||
if (Number.isNaN(since)) return ''
|
||||
return duration((Date.now() - since) / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why something was withheld, in words a visitor can act on.
|
||||
*
|
||||
* `what` completes the sentence — "who they are", "what players did". The
|
||||
* audience is the operator's (`staff` unless widened), and only `signed_in` is
|
||||
* something a visitor can do anything about.
|
||||
*/
|
||||
export function hiddenMessage(audience, what = 'who they are') {
|
||||
if (audience === 'signed_in') return `Sign in to see ${what}.`
|
||||
if (audience === 'public') return `This site is not showing ${what} right now.`
|
||||
return `Only this site’s staff can see ${what}.`
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
// id the events and the leaderboard filter by. There is no second derivation
|
||||
// anywhere that could disagree.
|
||||
|
||||
import { EmptyState, ErrorState, Loading, useAsync } from '../core.js'
|
||||
import { ErrorState, Loading, useAsync } from '../core.js'
|
||||
import Empty from './Empty.jsx'
|
||||
import { ago, day } from '../lib/format.js'
|
||||
import api from '../api.js'
|
||||
|
||||
@@ -24,7 +25,7 @@ export default function Wipes({ serverId, currentWipeId, selected, onSelect }) {
|
||||
|
||||
if (wipes.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
<Empty
|
||||
title="No wipes recorded"
|
||||
message="A wipe appears here once this server has reported something during it."
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user