// ── Admin · Rust · Visibility ─────────────────────────────────────────────
//
// Who may see who is online. The org lead's rule (2026-09-22): nothing names who
// is online by default — the narrowest audience, staff, unless an operator
// deliberately widens it here. A count of players is public at every setting.
//
// One fleet default and an optional override per server, because a creative or
// PvE server may reasonably publish a roll call a PvP server must not — and a
// server that has not chosen follows the fleet, so narrowing the fleet narrows
// every server that never said otherwise.
//
// The page says what "who is online" covers, because it is wider than the tab
// of the same name: the killfeed, chat and joins in the feed, and the
// leaderboard's "last seen" all name a player who was on at a given moment.
//
// Phase 9 adds a second setting beside it: who may see a CLAN ROSTER (D48). It
// defaults to the clan's own members and staff, and widening it widens online
// status too, because a roster row carries it — the page says so. The same card
// lists each server's clan board: a server whose clans cannot be read, one at
// the game's 100-clan ceiling (D55), and one running the uMod Clans plugin,
// whose clans are a separate system and never Teams (D47).
import { useCallback, useEffect, useState } from 'react'
import { ErrorState, Loading, useAsync } from '../../core.js'
import api from '../../api.js'
const INHERIT = ''
const LABEL = {
staff: 'Staff only',
signed_in: 'Signed-in members',
public: 'Everyone',
}
const CLAN_LABEL = {
members: 'The clan’s members and staff',
signed_in: 'Signed-in members',
public: 'Everyone',
}
const CLAN_DESCRIBE = {
members: 'Players whose linked Rust account is in the clan, plus admins and moderators. The default.',
signed_in: 'Anybody with an account on this site.',
public: 'Anybody at all, signed in or not.',
}
const DESCRIBE = {
staff: 'Admins and moderators. The default.',
signed_in: 'Anybody with an account on this site.',
public: 'Anybody at all, signed in or not.',
}
function Card({ title, subtitle, children }) {
return (
{title}
{subtitle && (
{subtitle}
)}
{children}
)
}
function AudienceSelect({ value, onChange, audiences, inherit = null, label }) {
return (
)
}
export default function Visibility() {
const [reloads, setReloads] = useState(0)
const { data, error: loadError } = useAsync(() => api.adminVisibility.read(), [reloads])
const [fleet, setFleet] = useState('staff')
const [clanRoster, setClanRoster] = useState('members')
const [servers, setServers] = useState({})
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [saved, setSaved] = useState(false)
// The form starts from what the server said and is reset from it after every
// save — the answer to a PUT is the new state, so what is on screen is always
// the site's word rather than what this page sent.
const load = useCallback((state) => {
setFleet(state.presence.fleet)
setClanRoster((state.clans && state.clans.roster) || 'members')
setServers(Object.fromEntries(state.presence.servers.map((s) => [s.id, s.override || INHERIT])))
}, [])
useEffect(() => {
if (data) load(data)
}, [data, load])
if (loadError) return
if (!data) return
const audiences = data.audiences
const rows = data.presence.servers
const dirtyFleet = fleet !== data.presence.fleet
const dirtyServers = rows.filter((s) => (servers[s.id] ?? INHERIT) !== (s.override || INHERIT))
const clans = data.clans || { audiences: [], roster: 'members', servers: [] }
const dirtyClans = clanRoster !== clans.roster
const dirty = dirtyFleet || dirtyServers.length > 0 || dirtyClans
const effective = (id) => servers[id] || fleet
const widened = fleet !== 'staff' || rows.some((s) => effective(s.id) !== 'staff')
const save = async (e) => {
e.preventDefault()
setBusy(true)
setError('')
setSaved(false)
try {
const body = {}
if (dirtyFleet) body.fleet = fleet
if (dirtyClans) body.clanRoster = clanRoster
if (dirtyServers.length) {
body.servers = Object.fromEntries(dirtyServers.map((s) => [s.id, servers[s.id] || null]))
}
load(await api.adminVisibility.save(body))
setSaved(true)
setReloads((n) => n + 1)
} catch (err) {
setError(err.message || 'That did not save.')
} finally {
setBusy(false)
}
}
return (
)
}
/**
* What each server's clan board says about itself. Only the servers with
* something to report are listed: a board that is current, complete and read
* normally is the case that needs no sentence.
*/
function ClanBoards({ servers }) {
const notes = []
for (const s of servers) {
if (s.umodClans) {
notes.push([s, 'is running the uMod Clans plugin. Its clans are a separate system from the game’s own, and only the game’s clans appear on this site.'])
}
if (!s.supported) {
notes.push([s, s.reason ? `cannot report its clans: ${s.reason}.` : 'has not reported its clans yet.'])
} else if (s.truncated) {
notes.push([s, 'is at the game’s limit of 100 listed clans, so clans beyond the top 100 by score are not shown, and a disbanded clan is not removed until it drops below.'])
} else if (!s.fresh) {
notes.push([s, 'has not reported its clans recently, so they are shown as last reported.'])
}
}
if (!notes.length) return null
return (