fix(rust): say who is online is withheld, never that nobody is #49
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 site’s 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 site’s 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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")))
|
||||
|
||||
Reference in New Issue
Block a user