fix(rust): say who is online is withheld, never that nobody is #49

Merged
whitlocktech merged 1 commits from fix/rust-presence-visibility into edge 2026-09-23 05:36:36 +00:00
7 changed files with 193 additions and 28 deletions

View File

@@ -70,10 +70,20 @@ data class RustServerDto(
val stale: Boolean = false,
)
/** `GET /public/rust/servers/{id}/events` — the killfeed and everything else public. */
/**
* `GET /public/rust/servers/{id}/events` — the killfeed and everything else public.
*
* **Nothing names who is online by default** (org lead, 2026-09-22). Below the
* operator's presence audience — staff unless widened — the server withholds
* every item that says a named player was on (joins, deaths, chat, tallies) and
* says so with [presenceHidden]; [presenceAudience] is who CAN see them. The
* screen says it, so a thin feed reads as withheld rather than as a quiet server.
*/
@Serializable
data class RustEventListDto(
val events: List<RustEventDto> = emptyList(),
val presenceHidden: Boolean = false,
val presenceAudience: String? = null,
)
/**
@@ -171,10 +181,20 @@ data class RustWipeDto(
val lastSeen: String? = null,
)
/** `GET /public/rust/servers/{id}/online` — who is on right now. */
/**
* `GET /public/rust/servers/{id}/online` — who is on right now.
*
* Below the operator's presence audience the names are withheld: [hidden] is
* true, [players] is empty and [count] is still the real number — a count names
* nobody, and it is already on the server line. An empty list with [hidden] set
* must never render as "nobody is on".
*/
@Serializable
data class RustOnlineDto(
val players: List<RustPresenceDto> = emptyList(),
val hidden: Boolean = false,
val count: Int = 0,
val audience: String? = null,
)
/**

View File

@@ -7,9 +7,9 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.map
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.RustApi
import com.runicgateway.app.data.api.dto.RustEventDto
import com.runicgateway.app.data.api.dto.RustEventListDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustPresenceDto
import com.runicgateway.app.data.api.dto.RustOnlineDto
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustWipeDto
import javax.inject.Inject
@@ -50,14 +50,14 @@ class RustRepository @Inject constructor(
kinds: List<String> = emptyList(),
wipe: String? = null,
limit: Int? = null,
): ApiResult<List<RustEventDto>> = safeApiCall {
): ApiResult<RustEventListDto> = safeApiCall {
api.getEvents(
id = id,
kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","),
wipe = wipe?.takeIf { it.isNotBlank() },
limit = limit,
)
}.map { it.events }
}
/** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */
suspend fun leaderboard(
@@ -78,7 +78,12 @@ class RustRepository @Inject constructor(
suspend fun wipes(id: String): ApiResult<List<RustWipeDto>> =
safeApiCall { api.getWipes(id) }.map { it.wipes }
/** The presence board. Rows survive an unreachable server, by design. */
suspend fun online(id: String): ApiResult<List<RustPresenceDto>> =
safeApiCall { api.getOnline(id) }.map { it.players }
/**
* The presence board. Rows survive an unreachable server, by design.
*
* Answered whole rather than as its rows: `hidden` and `count` are what let
* the screen tell "withheld from you" from "nobody is on".
*/
suspend fun online(id: String): ApiResult<RustOnlineDto> =
safeApiCall { api.getOnline(id) }
}

View File

@@ -25,6 +25,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -33,8 +34,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RustEventDto
import com.runicgateway.app.data.api.dto.RustEventListDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustPresenceDto
import com.runicgateway.app.data.api.dto.RustOnlineDto
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustWipeDto
import com.runicgateway.app.ui.ErrorKind
@@ -252,7 +254,7 @@ private fun WipeFilter(
@Composable
private fun FeedPanel(
feed: Polled<List<RustEventDto>>,
feed: Polled<RustEventListDto>,
filterId: String,
onFilter: (String) -> Unit,
onRetry: () -> Unit,
@@ -275,17 +277,36 @@ private fun FeedPanel(
when (val s = feed.state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
is UiState.Success -> if (s.data.isEmpty()) {
EmptyView(stringResource(R.string.rust_feed_empty))
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (feed.refreshFailed) {
item { RefreshFailedLine() }
is UiState.Success -> {
// Said once, above the rows, so a thin feed reads as withheld
// rather than as a quiet server (org lead: nothing names who is
// online by default).
if (s.data.presenceHidden) {
Text(
text = stringResource(
if (s.data.presenceAudience == "signed_in") {
R.string.rust_feed_presence_hidden_signin
} else {
R.string.rust_feed_presence_hidden_staff
},
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
if (s.data.events.isEmpty()) {
EmptyView(stringResource(R.string.rust_feed_empty))
} else {
LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
if (feed.refreshFailed) {
item { RefreshFailedLine() }
}
items(s.data.events, key = { it.id }) { FeedRow(it) }
}
items(s.data, key = { it.id }) { FeedRow(it) }
}
}
}
@@ -444,14 +465,29 @@ private fun LeaderboardPanel(
@Composable
private fun OnlinePanel(
online: Polled<List<RustPresenceDto>>,
online: Polled<RustOnlineDto>,
serverOnline: Boolean,
onRetry: () -> Unit,
) {
when (val s = online.state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
is UiState.Success -> if (s.data.isEmpty()) {
// Withheld is not empty. Below the operator's audience the server sends
// the count and no names, and an empty list rendered as "nobody is on"
// would be a false statement about a full server.
is UiState.Success -> if (s.data.hidden) {
val count = s.data.count
EmptyView(
pluralStringResource(R.plurals.rust_online_hidden_count, count, count) + "\n" +
stringResource(
when (s.data.audience) {
"signed_in" -> R.string.rust_online_hidden_signin
"public" -> R.string.rust_online_hidden_public
else -> R.string.rust_online_hidden_staff
},
),
)
} else if (s.data.players.isEmpty()) {
EmptyView(
stringResource(
if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline,
@@ -479,7 +515,7 @@ private fun OnlinePanel(
if (online.refreshFailed) {
item { RefreshFailedLine() }
}
items(s.data, key = { it.steamId }) { player ->
items(s.data.players, key = { it.steamId }) { player ->
ShardCard(Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(16.dp),

View File

@@ -6,9 +6,9 @@ package com.runicgateway.app.ui.rust
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.RustEventDto
import com.runicgateway.app.data.api.dto.RustEventListDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustPresenceDto
import com.runicgateway.app.data.api.dto.RustOnlineDto
import com.runicgateway.app.data.api.dto.RustServerDto
import com.runicgateway.app.data.api.dto.RustWipeDto
import com.runicgateway.app.data.repository.RustRepository
@@ -52,8 +52,10 @@ data class RustServerUi(
val sort: String = RustSort.KILLS,
/** The wipe every panel is filtered to. **Null is all time**, not "unknown". */
val selectedWipe: String? = null,
val feed: Polled<List<RustEventDto>> = Polled(),
val online: Polled<List<RustPresenceDto>> = Polled(),
/** The whole answer, not its rows: `presenceHidden` is part of what it says. */
val feed: Polled<RustEventListDto> = Polled(),
/** Likewise — `hidden` and `count` are what tell "withheld" from "nobody". */
val online: Polled<RustOnlineDto> = Polled(),
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
val wipes: UiState<List<RustWipeDto>> = UiState.Loading,
)

View File

@@ -593,6 +593,17 @@
<string name="rust_feed_empty">Nothing has happened on this server yet — or not during the wipe you are looking at.</string>
<string name="rust_leaderboard_empty">Nobody has scored here yet.</string>
<string name="rust_wipes_empty">This server has not reported a wipe yet.</string>
<!-- Nothing names who is online by default (org lead, 2026-09-22). Below the
operator's audience the server sends a count and no names. -->
<plurals name="rust_online_hidden_count">
<item quantity="one">%1$d player online</item>
<item quantity="other">%1$d players online</item>
</plurals>
<string name="rust_online_hidden_staff">Only this sites staff can see who they are.</string>
<string name="rust_online_hidden_signin">Sign in to see who they are.</string>
<string name="rust_online_hidden_public">This site is not showing who they are right now.</string>
<string name="rust_feed_presence_hidden_staff">Joins, deaths and chat are not shown. Only this sites staff can see what players did.</string>
<string name="rust_feed_presence_hidden_signin">Joins, deaths and chat are not shown. Sign in to see what players did.</string>
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string>
<string name="rust_presence_offline">Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.</string>
<string name="rust_presence_live">On the server right now.</string>

View File

@@ -0,0 +1,61 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Nothing names who is online by default (org lead, 2026-09-22). These are the
* answers module-rust's public routes give below and inside the operator's
* presence audience, copied from a live walk against the module — and the old
* shape, from a core running a module that predates the flag.
*/
class RustPresenceDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun withheldOnlineCarriesTheCountAndNoNames() {
val dto = json.decodeFromString<RustOnlineDto>(
"""{"players":[],"hidden":true,"count":2,"audience":"staff"}""",
)
assertTrue(dto.hidden)
assertEquals(2, dto.count)
assertEquals("staff", dto.audience)
assertTrue(dto.players.isEmpty())
}
@Test fun visibleOnlineNamesThePlayers() {
val dto = json.decodeFromString<RustOnlineDto>(
"""{"players":[{"steamId":"76561198000000002","name":"Builder Bea","sleeping":false,
"connectedAt":"2026-09-23T05:02:30.000Z"}],"hidden":false,"count":1,"audience":"staff"}""",
)
assertFalse(dto.hidden)
assertEquals("Builder Bea", dto.players.single().name)
}
@Test fun anOlderModuleWithoutTheFlagReadsAsVisible() {
val dto = json.decodeFromString<RustOnlineDto>("""{"players":[]}""")
assertFalse(dto.hidden)
assertNull(dto.audience)
}
@Test fun aWithheldFeedSaysSo() {
val dto = json.decodeFromString<RustEventListDto>(
"""{"events":[{"id":11,"kind":"server.wipe","t":1789500000000,"wipeId":"w-20260920T000000Z",
"steamId":null,"frame":{}}],"presenceHidden":true,"presenceAudience":"signed_in"}""",
)
assertTrue(dto.presenceHidden)
assertEquals("signed_in", dto.presenceAudience)
assertEquals("server.wipe", dto.events.single().kind)
}
}

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui.rust
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.data.api.dto.RustEventListDto
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
import com.runicgateway.app.data.api.dto.RustOnlineDto
@@ -100,6 +101,35 @@ class RustServerViewModelTest {
assertEquals(onlineBefore + 1, api.onlineCalls)
}
@Test
fun `withheld names arrive as withheld, with the count, never as an empty list`() {
// Nothing names who is online by default (org lead, 2026-09-22). The
// screen's whole job with this answer is to say "12 online" rather than
// "nobody is on", so the state must carry `hidden` and `count` intact.
api.online = RustOnlineDto(players = emptyList(), hidden = true, count = 12, audience = "staff")
val vm = viewModel()
vm.selectTab(RustTab.ONLINE)
val state = vm.state.value.online.state
assertTrue(state is UiState.Success)
val answer = (state as UiState.Success).data
assertTrue(answer.hidden)
assertEquals(12, answer.count)
assertEquals("staff", answer.audience)
}
@Test
fun `a feed with its players withheld says so in the state`() {
api.events = RustEventListDto(events = emptyList(), presenceHidden = true, presenceAudience = "signed_in")
val vm = viewModel()
val state = vm.state.value.feed.state
assertTrue(state is UiState.Success)
assertTrue((state as UiState.Success).data.presenceHidden)
assertEquals("signed_in", state.data.presenceAudience)
}
@Test
fun `a poll on a still panel asks for nothing but the server line`() {
api.wipes = RustWipeListDto(listOf(RustWipeDto(wipeId = "w1")))