feat(safety): report, block and hide chat lines in the app #64
@@ -12,6 +12,7 @@ import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||
import com.runicgateway.app.data.api.dto.RustWipeListDto
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
@@ -108,4 +109,18 @@ interface RustApi {
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}/map/live")
|
||||
suspend fun getMapLive(@Path("id") id: String): RustMapLiveDto
|
||||
|
||||
// ── Staff's hide for everyone (USER_SAFETY.md §4.2, S10) ─────────────────
|
||||
|
||||
/**
|
||||
* Hide one chat line for everybody but staff, who still get it with `hidden:
|
||||
* true`. Admins and moderators; anybody else is a 403. [lineId] is the event
|
||||
* row's id, and an id that is not a chat line is a 404.
|
||||
*/
|
||||
@POST("api/v1/admin/rust/chat/{id}/hide")
|
||||
suspend fun hideChat(@Path("id") lineId: Long): Unit
|
||||
|
||||
/** Undo [hideChat] — the only place a hide for everyone is undone. */
|
||||
@POST("api/v1/admin/rust/chat/{id}/unhide")
|
||||
suspend fun unhideChat(@Path("id") lineId: Long): Unit
|
||||
}
|
||||
|
||||
65
app/src/main/java/com/runicgateway/app/data/api/SafetyApi.kt
Normal file
65
app/src/main/java/com/runicgateway/app/data/api/SafetyApi.kt
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.BlockListDto
|
||||
import com.runicgateway.app.data.api.dto.BlockResultDto
|
||||
import com.runicgateway.app.data.api.dto.HiddenListDto
|
||||
import com.runicgateway.app.data.api.dto.ReportRequest
|
||||
import com.runicgateway.app.data.api.dto.ReportResultDto
|
||||
import com.runicgateway.app.data.api.dto.SafetyOkDto
|
||||
import com.runicgateway.app.data.api.dto.SafetyTargetRequest
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Report, block and hide (`docs/website/USER_SAFETY.md` §4.1, MODULE_API 1.21.0).
|
||||
*
|
||||
* **Core's routes, not a module's**: the Reports queue, a member's blocks and their
|
||||
* *Hide for me* list are the site's, and hold for the forum and every game module
|
||||
* alike. A block made here hides the same author on the website, and one made there
|
||||
* hides them here — they are saved on the account (S2).
|
||||
*
|
||||
* All of them need a signed-in account (S6), and all of them are role-agnostic: a
|
||||
* staff member reports and hides like anybody else. They ride the main client, so
|
||||
* the bearer is attached and a 401 is refreshed.
|
||||
*/
|
||||
interface SafetyApi {
|
||||
|
||||
/**
|
||||
* File a report. A report changes nothing about the item; it opens a row in the
|
||||
* staff Reports queue. An item this account could not have seen is a 404, the
|
||||
* same as one that does not exist; an open report of its own on the same item is
|
||||
* a 409.
|
||||
*/
|
||||
@POST("api/v1/player/reports")
|
||||
suspend fun report(@Body body: ReportRequest): ReportResultDto
|
||||
|
||||
@GET("api/v1/auth/me/blocks")
|
||||
suspend fun blocks(): BlockListDto
|
||||
|
||||
/**
|
||||
* Block the author of an item. `403` is S8's refusal (the author is staff), `409`
|
||||
* an existing block, `404` an item this account cannot see.
|
||||
*/
|
||||
@POST("api/v1/auth/me/blocks")
|
||||
suspend fun block(@Body body: SafetyTargetRequest): BlockResultDto
|
||||
|
||||
@DELETE("api/v1/auth/me/blocks/{id}")
|
||||
suspend fun unblock(@Path("id") id: Long): SafetyOkDto
|
||||
|
||||
@GET("api/v1/auth/me/hidden")
|
||||
suspend fun hidden(): HiddenListDto
|
||||
|
||||
/** *Hide for me*: this one item, for this account, on every device. */
|
||||
@POST("api/v1/auth/me/hidden")
|
||||
suspend fun hide(@Body body: SafetyTargetRequest): SafetyOkDto
|
||||
|
||||
/** Account → Blocked's *Show them again*: every item this account hid. */
|
||||
@DELETE("api/v1/auth/me/hidden")
|
||||
suspend fun clearHidden(): SafetyOkDto
|
||||
}
|
||||
@@ -118,6 +118,11 @@ data class RustEventListDto(
|
||||
* [t] is epoch milliseconds — the stamp the plugin put on the frame, not a
|
||||
* database column, so it is a number here and an ISO string everywhere else on
|
||||
* this wire.
|
||||
*
|
||||
* [canBlock] and [hidden] are about the row rather than in the frame, and only chat
|
||||
* lines carry them (`USER_SAFETY.md` §4.2). [canBlock] is sent to a signed-in reader
|
||||
* only (S13): null means "not said", and the menu offers no Block on it. [hidden]
|
||||
* reaches staff only — everybody else is never sent a line staff hid (S10).
|
||||
*/
|
||||
@Serializable
|
||||
data class RustEventDto(
|
||||
@@ -127,6 +132,8 @@ data class RustEventDto(
|
||||
val wipeId: String? = null,
|
||||
val steamId: String? = null,
|
||||
val frame: JsonObject = JsonObject(emptyMap()),
|
||||
val canBlock: Boolean? = null,
|
||||
val hidden: Boolean = false,
|
||||
) {
|
||||
/**
|
||||
* One frame field as text, or null.
|
||||
|
||||
103
app/src/main/java/com/runicgateway/app/data/api/dto/SafetyDto.kt
Normal file
103
app/src/main/java/com/runicgateway/app/data/api/dto/SafetyDto.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Report, block and hide (`docs/website/USER_SAFETY.md` §4.1): core's routes, which
|
||||
* hold for every module and the forum alike.
|
||||
*
|
||||
* **Every request names an item, never a person.** The server reads who wrote it
|
||||
* through the same resolver a report uses, so the app can only block someone whose
|
||||
* words it was shown. A Rust chat line is `{ targetType: "rust.chat", targetId:
|
||||
* <the event row's id> }`.
|
||||
*
|
||||
* No field on a request has a default: kotlinx leaves out a property equal to its
|
||||
* default, and a request missing `targetId` is a 400 nobody would see coming.
|
||||
*/
|
||||
@Serializable
|
||||
data class SafetyTargetRequest(
|
||||
val targetType: String,
|
||||
val targetId: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /player/reports`. [detail] is optional on the wire, and a null one is left
|
||||
* out (the app's `explicitNulls = false`) — the server's validator accepts an absent
|
||||
* detail and refuses a null one.
|
||||
*/
|
||||
@Serializable
|
||||
data class ReportRequest(
|
||||
val targetType: String,
|
||||
val targetId: Long,
|
||||
val reason: String,
|
||||
val detail: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ReportResultDto(
|
||||
val ok: Boolean = false,
|
||||
val reportId: Long? = null,
|
||||
)
|
||||
|
||||
/** `GET /auth/me/blocks`. */
|
||||
@Serializable
|
||||
data class BlockListDto(
|
||||
val blocks: List<UserBlockDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** `POST /auth/me/blocks` answers the block it made. */
|
||||
@Serializable
|
||||
data class BlockResultDto(
|
||||
val block: UserBlockDto? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One block.
|
||||
*
|
||||
* [kind] is `account` (a member, or a game player whose identity is linked to one —
|
||||
* S12) or `game` (an identity nobody has linked yet, which is when [identity] is
|
||||
* set). [applies] is false when the target is now staff, who cannot be blocked (S8),
|
||||
* and [reason] then says `staff`: the row stays so the member can see why it stopped.
|
||||
*/
|
||||
@Serializable
|
||||
data class UserBlockDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "account",
|
||||
val name: String? = null,
|
||||
val identity: UserBlockIdentityDto? = null,
|
||||
val applies: Boolean = true,
|
||||
val reason: String? = null,
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
/** The game identity a `game` block names: `Steam` and a 17-digit id, for Rust. */
|
||||
@Serializable
|
||||
data class UserBlockIdentityDto(
|
||||
val module: String? = null,
|
||||
val kind: String? = null,
|
||||
val label: String? = null,
|
||||
val id: String? = null,
|
||||
)
|
||||
|
||||
/** `GET /auth/me/hidden` — the items this member hid for themself. */
|
||||
@Serializable
|
||||
data class HiddenListDto(
|
||||
val hidden: List<HiddenItemDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class HiddenItemDto(
|
||||
val targetType: String = "",
|
||||
val targetId: Long = 0,
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
/** The `{ ok }` most of these routes answer, and `{ ok, cleared }` for clear-all. */
|
||||
@Serializable
|
||||
data class SafetyOkDto(
|
||||
val ok: Boolean = false,
|
||||
val cleared: Int? = null,
|
||||
)
|
||||
@@ -96,4 +96,12 @@ class RustRepository @Inject constructor(
|
||||
/** What moves on the map, as this viewer may see it. */
|
||||
suspend fun mapLive(id: String): ApiResult<RustMapLiveDto> =
|
||||
safeApiCall { api.getMapLive(id) }
|
||||
|
||||
/**
|
||||
* Staff's hide for everyone on one chat line, or its undo (`USER_SAFETY.md`
|
||||
* §4.2, S10). The module's own route, not core's: the line and its flag live here.
|
||||
*/
|
||||
suspend fun setChatHidden(lineId: Long, hidden: Boolean): ApiResult<Unit> = safeApiCall {
|
||||
if (hidden) api.hideChat(lineId) else api.unhideChat(lineId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.isDemoLocked
|
||||
import com.runicgateway.app.core.result.map
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.SafetyApi
|
||||
import com.runicgateway.app.data.api.dto.UserBlockDto
|
||||
import com.runicgateway.app.data.api.dto.HiddenItemDto
|
||||
import com.runicgateway.app.data.api.dto.ReportRequest
|
||||
import com.runicgateway.app.data.api.dto.SafetyTargetRequest
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Why the server refused a report, a block or a hide — one per answer a screen words differently. */
|
||||
enum class SafetyRefusal {
|
||||
/** `409`: an open report of this account's own on the item, or an existing block. */
|
||||
ALREADY_DONE,
|
||||
|
||||
/** `404`: gone, or never something this account could see. */
|
||||
GONE,
|
||||
|
||||
/** `403` on a block: the author is staff, who cannot be blocked (S8). */
|
||||
STAFF,
|
||||
|
||||
/** `403` anywhere else: this account may not (a demoted moderator hiding for everyone). */
|
||||
FORBIDDEN,
|
||||
|
||||
/** `400` on a block: their own line, or an author whose account is gone. */
|
||||
NOT_BLOCKABLE,
|
||||
RATE_LIMITED,
|
||||
|
||||
/** `503`: the game module that owns the item did not answer in time. */
|
||||
UNAVAILABLE,
|
||||
|
||||
/** The public demo's refusal; the shell's snackbar has already said why. */
|
||||
DEMO_LOCKED,
|
||||
|
||||
/** Anything else — a 5xx, or an answer this app does not know. */
|
||||
OTHER,
|
||||
}
|
||||
|
||||
sealed interface SafetyOutcome {
|
||||
data object Done : SafetyOutcome
|
||||
data class Refused(val reason: SafetyRefusal) : SafetyOutcome
|
||||
data object NetworkError : SafetyOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Report, block and hide (`docs/website/USER_SAFETY.md` §4.1, §4.3) over core's
|
||||
* routes.
|
||||
*
|
||||
* **Nothing here keeps a list.** The server filters every feed for its reader (S11),
|
||||
* so a block shows itself by the feed being read again — the app never drops a line
|
||||
* on its own, and never holds a block list that could disagree with the website's.
|
||||
*/
|
||||
@Singleton
|
||||
class SafetyRepository @Inject constructor(
|
||||
private val api: SafetyApi,
|
||||
) {
|
||||
suspend fun report(targetType: String, targetId: Long, reason: String, detail: String?): SafetyOutcome =
|
||||
outcome(blocking = false) {
|
||||
api.report(ReportRequest(targetType, targetId, reason, detail?.trim()?.takeIf { it.isNotEmpty() }))
|
||||
}
|
||||
|
||||
/** Block whoever wrote this item. The server decides who that is (S12). */
|
||||
suspend fun blockAuthorOf(targetType: String, targetId: Long): SafetyOutcome =
|
||||
outcome(blocking = true) { api.block(SafetyTargetRequest(targetType, targetId)) }
|
||||
|
||||
/** *Hide for me*. */
|
||||
suspend fun hide(targetType: String, targetId: Long): SafetyOutcome =
|
||||
outcome(blocking = false) { api.hide(SafetyTargetRequest(targetType, targetId)) }
|
||||
|
||||
suspend fun blocks(): ApiResult<List<UserBlockDto>> = safeApiCall { api.blocks() }.map { it.blocks }
|
||||
|
||||
suspend fun unblock(id: Long): SafetyOutcome = outcome(blocking = false) { api.unblock(id) }
|
||||
|
||||
suspend fun hidden(): ApiResult<List<HiddenItemDto>> = safeApiCall { api.hidden() }.map { it.hidden }
|
||||
|
||||
suspend fun clearHidden(): SafetyOutcome = outcome(blocking = false) { api.clearHidden() }
|
||||
|
||||
private suspend fun outcome(blocking: Boolean, call: suspend () -> Any): SafetyOutcome =
|
||||
safetyOutcomeOf(safeApiCall { call() }, blocking)
|
||||
}
|
||||
|
||||
/**
|
||||
* One answer as a [SafetyOutcome]. [blocking] is whether the call was a block, the
|
||||
* one route whose `403` means "staff" and whose `400` means "not someone you can
|
||||
* block". Shared with the staff hide, which is the module's route but answers in the
|
||||
* same terms.
|
||||
*/
|
||||
fun safetyOutcomeOf(result: ApiResult<*>, blocking: Boolean = false): SafetyOutcome = when (result) {
|
||||
is ApiResult.Ok -> SafetyOutcome.Done
|
||||
is ApiResult.NetworkError -> SafetyOutcome.NetworkError
|
||||
is ApiResult.HttpError -> SafetyOutcome.Refused(
|
||||
when {
|
||||
result.isDemoLocked() -> SafetyRefusal.DEMO_LOCKED
|
||||
result.status == 409 -> SafetyRefusal.ALREADY_DONE
|
||||
result.status == 404 -> SafetyRefusal.GONE
|
||||
result.status == 403 -> if (blocking) SafetyRefusal.STAFF else SafetyRefusal.FORBIDDEN
|
||||
result.status == 400 && blocking -> SafetyRefusal.NOT_BLOCKABLE
|
||||
result.status == 429 -> SafetyRefusal.RATE_LIMITED
|
||||
result.status == 503 -> SafetyRefusal.UNAVAILABLE
|
||||
else -> SafetyRefusal.OTHER
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import com.runicgateway.app.data.api.PlayerRustApi
|
||||
import com.runicgateway.app.data.api.PlayerShardApi
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import com.runicgateway.app.data.api.RustApi
|
||||
import com.runicgateway.app.data.api.SafetyApi
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -123,6 +124,11 @@ object NetworkModule {
|
||||
@Singleton
|
||||
fun provideMeApi(retrofit: Retrofit): MeApi = retrofit.create(MeApi::class.java)
|
||||
|
||||
/** Report, block and hide (USER_SAFETY.md §4.1) — core's routes, bearer-authed. */
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSafetyApi(retrofit: Retrofit): SafetyApi = retrofit.create(SafetyApi::class.java)
|
||||
|
||||
/** A player's own game data + linking (§6.3) — bearer-authed on the main client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
|
||||
@@ -62,6 +62,7 @@ import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import com.runicgateway.app.data.repository.Capability
|
||||
import com.runicgateway.app.ui.auth.AccountScreen
|
||||
import com.runicgateway.app.ui.auth.BlockedScreen
|
||||
import com.runicgateway.app.ui.auth.DeleteAccountScreen
|
||||
import com.runicgateway.app.ui.auth.DeleteAccountViewModel
|
||||
import com.runicgateway.app.ui.auth.LoginScreen
|
||||
@@ -680,6 +681,8 @@ private fun RunicNavHost(
|
||||
onBack = { navController.navigateTopLevel(Routes.RUST) },
|
||||
// D123: a site-event marker opens the app's own event page on its run.
|
||||
onOpenEvent = { slug, runId -> navController.navigate(Routes.event(slug, runId)) },
|
||||
// A chat line's menu signed out (S6): report and block need an account.
|
||||
onSignIn = { navController.navigate(Routes.LOGIN) },
|
||||
)
|
||||
}
|
||||
composable(Routes.WIKI) {
|
||||
@@ -723,6 +726,7 @@ private fun RunicNavHost(
|
||||
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
|
||||
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
|
||||
onOpenDeleteAccount = { navController.navigate(Routes.ACCOUNT_DELETE) },
|
||||
onOpenBlocked = { navController.navigate(Routes.ACCOUNT_BLOCKED) },
|
||||
)
|
||||
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||
navController.navigateTopLevel(Routes.HOME)
|
||||
@@ -736,6 +740,12 @@ private fun RunicNavHost(
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT_BLOCKED) {
|
||||
when (session) {
|
||||
is Session.SignedIn -> BlockedScreen()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT_RECOVERY_CODES) {
|
||||
when (session) {
|
||||
is Session.SignedIn -> RecoveryCodesScreen()
|
||||
|
||||
@@ -70,6 +70,7 @@ fun AccountScreen(
|
||||
onOpenTrustedDevices: () -> Unit,
|
||||
onOpenRecoveryCodes: () -> Unit,
|
||||
onOpenDeleteAccount: () -> Unit,
|
||||
onOpenBlocked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -94,6 +95,8 @@ fun AccountScreen(
|
||||
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||
}
|
||||
|
||||
BlockedRow(onOpenBlocked)
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||
|
||||
OutlinedButton(onClick = onSignOut, modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -139,6 +142,27 @@ private fun DeleteAccountRow(onOpenDeleteAccount: () -> Unit) {
|
||||
) { Text(stringResource(R.string.account_delete_open)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Account → Blocked (USER_SAFETY.md §4.3). Outside the loaded sections on purpose:
|
||||
* the blocks are their own read, and a failed account read is no reason to hide
|
||||
* the way to undo one.
|
||||
*/
|
||||
@Composable
|
||||
private fun BlockedRow(onOpenBlocked: () -> Unit) {
|
||||
SectionCard(R.string.blocked_title) {
|
||||
Text(
|
||||
stringResource(R.string.blocked_row_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onOpenBlocked,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.blocked_open)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdentityCard(username: String, roleLabel: String) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
|
||||
156
app/src/main/java/com/runicgateway/app/ui/auth/BlockedScreen.kt
Normal file
156
app/src/main/java/com/runicgateway/app/ui/auth/BlockedScreen.kt
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.UserBlockDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* Account → Blocked (`USER_SAFETY.md` §4.3), mirroring the website's panel: who this
|
||||
* account blocked, each with *Unblock*, and *Show them again* for what it hid.
|
||||
*/
|
||||
@Composable
|
||||
fun BlockedScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: BlockedViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.blocked_title), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
stringResource(R.string.blocked_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
state.feedback?.let { fb ->
|
||||
Text(
|
||||
text = fb.arg?.let { stringResource(fb.messageRes, it) } ?: stringResource(fb.messageRes),
|
||||
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when (val blocks = state.blocks) {
|
||||
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
|
||||
is UiState.Error -> ErrorView(blocks.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
|
||||
is UiState.Success -> {
|
||||
if (blocks.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.blocked_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
} else {
|
||||
blocks.data.forEach { block ->
|
||||
BlockRow(block, state.busy, onUnblock = { viewModel.unblock(block) })
|
||||
}
|
||||
}
|
||||
|
||||
if (state.hiddenCount > 0) {
|
||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||
Text(
|
||||
pluralStringResource(R.plurals.blocked_hidden_count, state.hiddenCount, state.hiddenCount),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::showHidden,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) { Text(stringResource(R.string.blocked_show_hidden)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlockRow(block: UserBlockDto, busy: Boolean, onUnblock: () -> Unit) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = block.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.blocked_unknown_player),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = blockSubtitle(block),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// No line limit: a Steam block's "not linked to an account" and
|
||||
// its date are the point of the row, and on the walk one line cut
|
||||
// both off and two still cut the year.
|
||||
)
|
||||
// S8: a block whose target is now staff stays listed, and says why
|
||||
// it stopped, rather than vanishing.
|
||||
if (!block.applies && block.reason == "staff") {
|
||||
Text(
|
||||
text = stringResource(R.string.blocked_not_applied_staff),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onUnblock, enabled = !busy) {
|
||||
Text(stringResource(R.string.blocked_unblock))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The second line of a row: what was blocked, and when. An account block is the
|
||||
* usual one (S12); a game block names the identity and says it is not linked to an
|
||||
* account yet, since that is why it is a Steam id and not a username.
|
||||
*/
|
||||
@Composable
|
||||
private fun blockSubtitle(block: UserBlockDto): String {
|
||||
val identity = block.identity
|
||||
val what = if (block.kind == "game" && identity?.id != null) {
|
||||
stringResource(R.string.blocked_kind_game, identity.label ?: identity.kind.orEmpty(), identity.id)
|
||||
} else {
|
||||
stringResource(R.string.blocked_kind_account)
|
||||
}
|
||||
val on = blockedOn(block.createdAt) ?: return what
|
||||
return stringResource(R.string.blocked_subtitle_when, what, on)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.time.parseWireInstant
|
||||
import com.runicgateway.app.data.api.dto.UserBlockDto
|
||||
import com.runicgateway.app.data.repository.SafetyOutcome
|
||||
import com.runicgateway.app.data.repository.SafetyRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Account → Blocked (`USER_SAFETY.md` §4.3), the app's copy of the website's panel:
|
||||
* the members and players this account blocked, each with *Unblock*, and a count of
|
||||
* the items it hid for itself with one *Show them again*.
|
||||
*
|
||||
* Blocks and hides are saved on the account (S2), so this is the same list the
|
||||
* website shows, and undoing one here undoes it there.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class BlockedViewModel @Inject constructor(
|
||||
private val safety: SafetyRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** A one-shot result line above the list. [arg] fills a name into [messageRes]. */
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int, val arg: String? = null)
|
||||
|
||||
data class State(
|
||||
val blocks: UiState<List<UserBlockDto>> = UiState.Loading,
|
||||
/** Items hidden for this account. Zero when the list could not be read: the row just isn't offered. */
|
||||
val hiddenCount: Int = 0,
|
||||
val busy: Boolean = false,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(blocks = UiState.Loading) }
|
||||
viewModelScope.launch { reload() }
|
||||
}
|
||||
|
||||
fun unblock(block: UserBlockDto) = act(
|
||||
call = { safety.unblock(block.id) },
|
||||
done = block.name?.let { Feedback(true, R.string.blocked_unblocked_named, it) }
|
||||
?: Feedback(true, R.string.blocked_unblocked),
|
||||
failed = R.string.blocked_unblock_failed,
|
||||
)
|
||||
|
||||
fun showHidden() = act(
|
||||
call = { safety.clearHidden() },
|
||||
done = Feedback(true, R.string.blocked_hidden_cleared),
|
||||
failed = R.string.blocked_hidden_failed,
|
||||
)
|
||||
|
||||
private fun act(call: suspend () -> SafetyOutcome, done: Feedback, @StringRes failed: Int) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
val feedback = when (call()) {
|
||||
SafetyOutcome.Done -> done
|
||||
SafetyOutcome.NetworkError -> Feedback(false, R.string.error_network)
|
||||
is SafetyOutcome.Refused -> Feedback(false, failed)
|
||||
}
|
||||
reload()
|
||||
_state.update { it.copy(busy = false, feedback = feedback) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reload() {
|
||||
// The two reads at once; a failed hidden-list read costs the one row, not the page.
|
||||
val (blocks, hidden) = coroutineScope {
|
||||
val b = async { safety.blocks() }
|
||||
val h = async { safety.hidden() }
|
||||
b.await() to h.await()
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
blocks = blocks.toUiState(),
|
||||
hiddenCount = (hidden as? ApiResult.Ok)?.data?.size ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a block was made, as a date in the reader's zone, or null when the server
|
||||
* sent no stamp it could read.
|
||||
*/
|
||||
fun blockedOn(createdAt: String?, zone: ZoneId = ZoneId.systemDefault()): String? =
|
||||
parseWireInstant(createdAt)?.let {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withZone(zone).format(it)
|
||||
}
|
||||
@@ -41,6 +41,9 @@ object Routes {
|
||||
/** Delete my account (ACCOUNT_DELETION.md §4.3), reached from Account. Signed-in only. */
|
||||
const val ACCOUNT_DELETE = "account/delete"
|
||||
|
||||
/** Account → Blocked (USER_SAFETY.md §4.3), reached from Account. Signed-in only. */
|
||||
const val ACCOUNT_BLOCKED = "account/blocked"
|
||||
|
||||
/**
|
||||
* The in-app inbox (ENGAGEMENT.md phase 8, signed-in) and its settings.
|
||||
*
|
||||
|
||||
72
app/src/main/java/com/runicgateway/app/ui/rust/ChatSafety.kt
Normal file
72
app/src/main/java/com/runicgateway/app/ui/rust/ChatSafety.kt
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.SessionUser
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
|
||||
/**
|
||||
* Report, block and hide on a chat line: what its menu offers (`USER_SAFETY.md`
|
||||
* §4.3) — the Kotlin half of `module-rust`'s `client/src/lib/safety.js`.
|
||||
*
|
||||
* **Nothing here decides what a reader may see.** The server already left out what
|
||||
* they blocked or hid, and staff's hidden lines for everybody else (S11). This file
|
||||
* only turns what the server said about a line into which actions its menu shows,
|
||||
* and it is pure so the JVM tests can ask all of it.
|
||||
*/
|
||||
|
||||
/** The report target a chat line is filed as, registered by the module's `server/safety.js`. */
|
||||
const val CHAT_TARGET = "rust.chat"
|
||||
|
||||
/** One reason a report can give: the wire value, and the words for it. */
|
||||
data class ReportReason(val wire: String, @param:StringRes val labelRes: Int)
|
||||
|
||||
/**
|
||||
* Core's `content_reports.reason` values, in the order and words the website's
|
||||
* menus use, so a member reads one list wherever they report from.
|
||||
*/
|
||||
val REPORT_REASONS: List<ReportReason> = listOf(
|
||||
ReportReason("abuse", R.string.safety_reason_abuse),
|
||||
ReportReason("spam", R.string.safety_reason_spam),
|
||||
ReportReason("sexual", R.string.safety_reason_sexual),
|
||||
ReportReason("illegal", R.string.safety_reason_illegal),
|
||||
ReportReason("impersonation", R.string.safety_reason_impersonation),
|
||||
ReportReason("other", R.string.safety_reason_other),
|
||||
)
|
||||
|
||||
/** Staff's action on a line: hide it for everyone, or show a hidden one again (S10). */
|
||||
enum class StaffHide { HIDE, UNHIDE }
|
||||
|
||||
/**
|
||||
* What a chat line's menu offers one reader.
|
||||
*
|
||||
* - [signIn] a signed-out reader gets one line telling them to sign in (S6)
|
||||
* - [report] any signed-in reader, on any line
|
||||
* - [hide] *Hide for me*, likewise — a staff member's line included (S8)
|
||||
* - [block] only where the server said `canBlock` (S8, S13)
|
||||
* - [staff] for an admin or moderator, the two roles the module's hide route admits
|
||||
*/
|
||||
data class ChatActions(
|
||||
val signIn: Boolean,
|
||||
val report: Boolean,
|
||||
val hide: Boolean,
|
||||
val block: Boolean,
|
||||
val staff: StaffHide?,
|
||||
)
|
||||
|
||||
/** The menu for [row] as [user] sees it, or null for a row that is not a chat line. */
|
||||
fun chatActions(row: RustEventDto, user: SessionUser?): ChatActions? {
|
||||
if (row.kind != "player.chat") return null
|
||||
if (user == null) return ChatActions(signIn = true, report = false, hide = false, block = false, staff = null)
|
||||
return ChatActions(
|
||||
signIn = false,
|
||||
report = true,
|
||||
hide = true,
|
||||
// `canBlock` absent is "not said", and not saying is not permission.
|
||||
block = row.canBlock == true,
|
||||
staff = if (user.isModerator) (if (row.hidden) StaffHide.UNHIDE else StaffHide.HIDE) else null,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.ModalBottomSheetProperties
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.runicgateway.app.R
|
||||
|
||||
/**
|
||||
* A chat line's sheet (`USER_SAFETY.md` §4.3), opened by a long press. Its wording
|
||||
* follows the website's `ChatMenu.jsx`, so a member reads one set of sentences in
|
||||
* either place.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatSafetySheet(
|
||||
menu: ChatMenuUi,
|
||||
actions: ChatActions,
|
||||
viewModel: ChatSafetyViewModel,
|
||||
onSignIn: () -> Unit,
|
||||
) {
|
||||
val line = menu.line ?: return
|
||||
val name = line.str("name") ?: stringResource(R.string.safety_this_player)
|
||||
|
||||
// Back on the report form or the block confirmation returns to the menu rather
|
||||
// than closing the sheet. Found on the walk: Back to put the keyboard away closed
|
||||
// the whole sheet and threw away a half-written report. The reason and detail
|
||||
// live in the view model, so stepping back and in again keeps them.
|
||||
//
|
||||
// The sheet never dismisses itself on Back, and this handler decides instead:
|
||||
// the sheet reads its properties once, when it opens on the menu, so a property
|
||||
// that changed with the step would never take effect.
|
||||
val inForm = menu.step == ChatMenuStep.REPORT || menu.step == ChatMenuStep.BLOCK
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = viewModel::close,
|
||||
// Open all the way: half-open, the report form's Send button sat below the
|
||||
// fold and the walk had to drag the sheet up to find it.
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
properties = ModalBottomSheetProperties(shouldDismissOnBackPress = false),
|
||||
) {
|
||||
BackHandler { if (inForm) viewModel.go(ChatMenuStep.MENU) else viewModel.close() }
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp),
|
||||
) {
|
||||
// The line itself, so the reader can see which one they pressed.
|
||||
Text(
|
||||
text = stringResource(R.string.safety_line_quote, name, line.str("message").orEmpty()),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
|
||||
when (menu.step) {
|
||||
ChatMenuStep.MENU -> MenuStep(actions, menu.busy, viewModel, onSignIn)
|
||||
ChatMenuStep.REPORT -> ReportStep(menu, viewModel)
|
||||
ChatMenuStep.BLOCK -> BlockStep(name, menu.busy, viewModel)
|
||||
ChatMenuStep.DONE -> {
|
||||
menu.doneRes?.let { Text(stringResource(it), style = MaterialTheme.typography.bodyMedium) }
|
||||
TextButton(onClick = viewModel::close, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text(stringResource(R.string.safety_close))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
menu.errorRes?.let {
|
||||
Text(
|
||||
text = stringResource(it),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp)) // clears the gesture inset at the sheet's bottom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuStep(actions: ChatActions, busy: Boolean, viewModel: ChatSafetyViewModel, onSignIn: () -> Unit) {
|
||||
if (actions.signIn) {
|
||||
// S6: reporting, hiding and blocking all need an account.
|
||||
Text(stringResource(R.string.safety_sign_in_hint), style = MaterialTheme.typography.bodyMedium)
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.close()
|
||||
onSignIn()
|
||||
},
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.safety_sign_in)) }
|
||||
return
|
||||
}
|
||||
|
||||
if (actions.report) {
|
||||
MenuItem(R.string.safety_report, enabled = !busy) { viewModel.go(ChatMenuStep.REPORT) }
|
||||
}
|
||||
if (actions.hide) {
|
||||
MenuItem(R.string.safety_hide_for_me, enabled = !busy, onClick = viewModel::hideForMe)
|
||||
}
|
||||
if (actions.block) {
|
||||
MenuItem(R.string.safety_block_player, enabled = !busy) { viewModel.go(ChatMenuStep.BLOCK) }
|
||||
}
|
||||
when (actions.staff) {
|
||||
StaffHide.HIDE -> MenuItem(R.string.safety_hide_for_everyone, enabled = !busy) { viewModel.staffHide(true) }
|
||||
StaffHide.UNHIDE -> MenuItem(R.string.safety_show_to_everyone, enabled = !busy) { viewModel.staffHide(false) }
|
||||
null -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MenuItem(labelRes: Int, enabled: Boolean, onClick: () -> Unit) {
|
||||
TextButton(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(labelRes), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Start)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReportStep(menu: ChatMenuUi, viewModel: ChatSafetyViewModel) {
|
||||
// A report changes nothing about the line, and says so: a member who expects it
|
||||
// to vanish and watches it stay will report it again.
|
||||
Text(stringResource(R.string.safety_report_explainer), style = MaterialTheme.typography.bodyMedium)
|
||||
|
||||
Column(Modifier.selectableGroup().padding(top = 8.dp)) {
|
||||
REPORT_REASONS.forEach { reason ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.selectable(
|
||||
selected = menu.reason == reason.wire,
|
||||
onClick = { viewModel.setReason(reason.wire) },
|
||||
role = Role.RadioButton,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
RadioButton(selected = menu.reason == reason.wire, onClick = null)
|
||||
Text(
|
||||
stringResource(reason.labelRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = menu.detail,
|
||||
onValueChange = viewModel::setDetail,
|
||||
label = { Text(stringResource(R.string.safety_report_detail)) },
|
||||
minLines = 2,
|
||||
maxLines = 5,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
|
||||
Row(Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = viewModel::report, enabled = !menu.busy) {
|
||||
Text(stringResource(R.string.safety_report_send))
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.go(ChatMenuStep.MENU) }) {
|
||||
Text(stringResource(R.string.safety_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlockStep(name: String, busy: Boolean, viewModel: ChatSafetyViewModel) {
|
||||
// S9: the blocked player is not told, and a member deciding whether to block is
|
||||
// usually deciding whether it will start something.
|
||||
Text(stringResource(R.string.safety_block_confirm, name), style = MaterialTheme.typography.bodyMedium)
|
||||
Row(Modifier.padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = viewModel::block, enabled = !busy) {
|
||||
Text(stringResource(R.string.safety_block))
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.go(ChatMenuStep.MENU) }) {
|
||||
Text(stringResource(R.string.safety_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.SessionUser
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.data.repository.SafetyOutcome
|
||||
import com.runicgateway.app.data.repository.SafetyRefusal
|
||||
import com.runicgateway.app.data.repository.SafetyRepository
|
||||
import com.runicgateway.app.data.repository.safetyOutcomeOf
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Which part of a chat line's sheet is showing. */
|
||||
enum class ChatMenuStep { MENU, REPORT, BLOCK, DONE }
|
||||
|
||||
/**
|
||||
* The open sheet, or none.
|
||||
*
|
||||
* [line] is the row the sheet is about, held whole so the sheet still names the
|
||||
* player after the feed has been re-read without it. [error] is the last refusal,
|
||||
* dropped on every move between steps: "you have already reported this" is not
|
||||
* about the next action.
|
||||
*/
|
||||
data class ChatMenuUi(
|
||||
val line: RustEventDto? = null,
|
||||
val step: ChatMenuStep = ChatMenuStep.MENU,
|
||||
val reason: String = REPORT_REASONS.first().wire,
|
||||
val detail: String = "",
|
||||
val busy: Boolean = false,
|
||||
@param:StringRes val doneRes: Int? = null,
|
||||
@param:StringRes val errorRes: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A chat line's long-press menu (`USER_SAFETY.md` §4.3): report, *Hide for me*, block
|
||||
* the player, and for staff hide for everyone.
|
||||
*
|
||||
* **The feed is re-read after anything that changes what the reader sees** — a hide,
|
||||
* a block, staff's hide — and never edited here. The server is what filters it (S11),
|
||||
* so the honest way to show a block is to ask again; dropping the line locally would
|
||||
* show something the website does not, the moment a refresh disagreed. [changes]
|
||||
* tells the screen when to ask. A report changes nothing, and does not re-read.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ChatSafetyViewModel @Inject constructor(
|
||||
private val safety: SafetyRepository,
|
||||
private val rust: RustRepository,
|
||||
private val sessionManager: SessionManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _menu = MutableStateFlow(ChatMenuUi())
|
||||
val menu: StateFlow<ChatMenuUi> = _menu.asStateFlow()
|
||||
|
||||
private val _changes = Channel<Unit>(Channel.CONFLATED)
|
||||
|
||||
/** One item per change to what this reader's feed should show. */
|
||||
val changes: Flow<Unit> = _changes.receiveAsFlow()
|
||||
|
||||
/** Who is reading, for [chatActions]. Read at the moment of asking, never cached. */
|
||||
val user: SessionUser? get() = (sessionManager.state.value as? Session.SignedIn)?.user
|
||||
|
||||
fun open(line: RustEventDto) {
|
||||
if (chatActions(line, user) == null) return
|
||||
_menu.value = ChatMenuUi(line = line)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
_menu.value = ChatMenuUi()
|
||||
}
|
||||
|
||||
fun go(step: ChatMenuStep) = _menu.update { it.copy(step = step, errorRes = null) }
|
||||
|
||||
fun setReason(reason: String) = _menu.update { it.copy(reason = reason) }
|
||||
|
||||
fun setDetail(detail: String) = _menu.update { it.copy(detail = detail.take(MAX_DETAIL)) }
|
||||
|
||||
fun report() {
|
||||
val m = _menu.value
|
||||
val line = m.line ?: return
|
||||
run(changesFeed = false, doneRes = R.string.safety_reported) {
|
||||
safety.report(CHAT_TARGET, line.id, m.reason, m.detail)
|
||||
}
|
||||
}
|
||||
|
||||
fun hideForMe() {
|
||||
val line = _menu.value.line ?: return
|
||||
run(changesFeed = true) { safety.hide(CHAT_TARGET, line.id) }
|
||||
}
|
||||
|
||||
fun block() {
|
||||
val line = _menu.value.line ?: return
|
||||
run(changesFeed = true, blocking = true) { safety.blockAuthorOf(CHAT_TARGET, line.id) }
|
||||
}
|
||||
|
||||
/** Staff's hide for everyone, or its undo, by what the line says it is now. */
|
||||
fun staffHide(hide: Boolean) {
|
||||
val line = _menu.value.line ?: return
|
||||
run(changesFeed = true) { safetyOutcomeOf(rust.setChatHidden(line.id, hide)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Do one action. Success closes the sheet, or shows [doneRes] when there is
|
||||
* something to say (a report, which changes nothing visible); a refusal stays
|
||||
* on the step it came from, with words for why.
|
||||
*/
|
||||
private fun run(
|
||||
changesFeed: Boolean,
|
||||
blocking: Boolean = false,
|
||||
@StringRes doneRes: Int? = null,
|
||||
call: suspend () -> SafetyOutcome,
|
||||
) {
|
||||
if (_menu.value.busy) return
|
||||
_menu.update { it.copy(busy = true, errorRes = null) }
|
||||
viewModelScope.launch {
|
||||
when (val outcome = call()) {
|
||||
SafetyOutcome.Done -> {
|
||||
_menu.value = if (doneRes != null) {
|
||||
_menu.value.copy(busy = false, step = ChatMenuStep.DONE, doneRes = doneRes)
|
||||
} else {
|
||||
ChatMenuUi()
|
||||
}
|
||||
if (changesFeed) _changes.trySend(Unit)
|
||||
}
|
||||
is SafetyOutcome.Refused -> {
|
||||
_menu.update { it.copy(busy = false, errorRes = refusalText(outcome.reason, blocking)) }
|
||||
// A line that is gone, or a block that already exists, means the
|
||||
// feed on screen is out of date: re-read it so it says what is true.
|
||||
if (outcome.reason == SafetyRefusal.GONE || outcome.reason == SafetyRefusal.ALREADY_DONE) {
|
||||
if (changesFeed) _changes.trySend(Unit)
|
||||
}
|
||||
}
|
||||
SafetyOutcome.NetworkError ->
|
||||
_menu.update { it.copy(busy = false, errorRes = R.string.error_network) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The server's own cap on a report's detail. */
|
||||
const val MAX_DETAIL = 500
|
||||
}
|
||||
}
|
||||
|
||||
/** Words for one refusal. [blocking] picks the block's own wording for a 409. */
|
||||
@StringRes
|
||||
fun refusalText(reason: SafetyRefusal, blocking: Boolean): Int = when (reason) {
|
||||
SafetyRefusal.ALREADY_DONE -> if (blocking) R.string.safety_error_already_blocked else R.string.safety_error_already_reported
|
||||
SafetyRefusal.GONE -> R.string.safety_error_gone
|
||||
SafetyRefusal.STAFF -> R.string.safety_error_staff
|
||||
SafetyRefusal.FORBIDDEN -> R.string.safety_error_forbidden
|
||||
SafetyRefusal.NOT_BLOCKABLE -> R.string.safety_error_not_blockable
|
||||
SafetyRefusal.RATE_LIMITED -> R.string.safety_error_rate_limited
|
||||
SafetyRefusal.UNAVAILABLE -> R.string.safety_error_unavailable
|
||||
SafetyRefusal.DEMO_LOCKED -> R.string.safety_error_demo
|
||||
SafetyRefusal.OTHER -> R.string.safety_error_other
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -25,6 +27,7 @@ import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -75,13 +78,24 @@ import com.runicgateway.app.ui.components.StatusPill
|
||||
fun RustServerScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||
onSignIn: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RustServerViewModel = hiltViewModel(),
|
||||
safety: ChatSafetyViewModel = hiltViewModel(),
|
||||
) {
|
||||
val ui by viewModel.state.collectAsStateWithLifecycle()
|
||||
val menu by safety.menu.collectAsStateWithLifecycle()
|
||||
|
||||
PollWhileResumed { viewModel.refresh() }
|
||||
|
||||
// The server filters the feed for its reader (S11), so a block or a hide shows
|
||||
// itself by the feed being read again, never by a line being dropped here.
|
||||
LaunchedEffect(safety) { safety.changes.collect { viewModel.reloadFeed() } }
|
||||
|
||||
menu.line?.let { line ->
|
||||
chatActions(line, safety.user)?.let { actions -> ChatSafetySheet(menu, actions, safety, onSignIn) }
|
||||
}
|
||||
|
||||
when (val s = ui.server.state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
|
||||
@@ -98,7 +112,7 @@ fun RustServerScreen(
|
||||
ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
}
|
||||
|
||||
is UiState.Success -> ServerDetail(s.data, ui, viewModel, onOpenEvent, modifier)
|
||||
is UiState.Success -> ServerDetail(s.data, ui, viewModel, onOpenEvent, safety::open, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +143,7 @@ private fun ServerDetail(
|
||||
ui: RustServerUi,
|
||||
viewModel: RustServerViewModel,
|
||||
onOpenEvent: (slug: String, runId: String) -> Unit,
|
||||
onLinePressed: (RustEventDto) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier.fillMaxSize()) {
|
||||
@@ -146,7 +161,13 @@ private fun ServerDetail(
|
||||
}
|
||||
|
||||
when (ui.tab) {
|
||||
RustTab.FEED -> FeedPanel(ui.feed, ui.filterId, viewModel::selectFilter, viewModel::retryFeed)
|
||||
RustTab.FEED -> FeedPanel(
|
||||
ui.feed,
|
||||
ui.filterId,
|
||||
viewModel::selectFilter,
|
||||
viewModel::retryFeed,
|
||||
onLinePressed,
|
||||
)
|
||||
RustTab.LEADERBOARD -> LeaderboardPanel(
|
||||
ui.leaderboard,
|
||||
ui.sort,
|
||||
@@ -279,6 +300,7 @@ private fun FeedPanel(
|
||||
filterId: String,
|
||||
onFilter: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onLinePressed: (RustEventDto) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
@@ -326,7 +348,7 @@ private fun FeedPanel(
|
||||
if (feed.refreshFailed) {
|
||||
item { RefreshFailedLine() }
|
||||
}
|
||||
items(s.data.events, key = { it.id }) { FeedRow(it) }
|
||||
items(s.data.events, key = { it.id }) { FeedRow(it, onLinePressed) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,11 +356,34 @@ private fun FeedPanel(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun FeedRow(row: RustEventDto) {
|
||||
private fun FeedRow(row: RustEventDto, onLinePressed: (RustEventDto) -> Unit) {
|
||||
val line = describe(row)
|
||||
val menuLabel = stringResource(R.string.safety_line_actions)
|
||||
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
// A chat line answers a long press with its report / hide / block menu
|
||||
// (USER_SAFETY.md §4.3). Only chat: a block hides what someone says, and the
|
||||
// other rows are things that happened to them. A line staff hid for everyone
|
||||
// reaches staff alone, dimmed and marked (S10).
|
||||
val chat = row.kind == "player.chat"
|
||||
Row(
|
||||
verticalAlignment = Alignment.Top,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (chat) {
|
||||
Modifier.combinedClickable(
|
||||
onClick = {},
|
||||
onLongClick = { onLinePressed(row) },
|
||||
onLongClickLabel = menuLabel,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.then(if (row.hidden) Modifier.alpha(HIDDEN_LINE_ALPHA) else Modifier),
|
||||
) {
|
||||
// The stamp carries a date for anything not from today — a row from six
|
||||
// weeks ago rendered as a bare time reads as this afternoon, which is
|
||||
// exactly what happens the moment the feed is filtered to a past wipe.
|
||||
@@ -370,10 +415,20 @@ private fun FeedRow(row: RustEventDto) {
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (row.hidden) {
|
||||
Text(
|
||||
text = stringResource(R.string.safety_hidden_by_staff),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How far a line staff hid for everyone is faded, for the staff who still see it. */
|
||||
private const val HIDDEN_LINE_ALPHA = 0.6f
|
||||
|
||||
// ── Leaderboard ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -258,6 +258,15 @@ class RustServerViewModel @Inject constructor(
|
||||
viewModelScope.launch { askFeed() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the feed again after a report, block or hide changed what this reader
|
||||
* should see (`USER_SAFETY.md` §4.3). The same question, so what is on screen
|
||||
* stays up until the answer replaces it, as a poll's does.
|
||||
*/
|
||||
fun reloadFeed() {
|
||||
viewModelScope.launch { askFeed() }
|
||||
}
|
||||
|
||||
fun retryLeaderboard() {
|
||||
_state.update { it.copy(leaderboard = UiState.Loading) }
|
||||
viewModelScope.launch { askLeaderboard() }
|
||||
|
||||
@@ -376,4 +376,27 @@
|
||||
<string name="events_score">Score %1$s</string>
|
||||
<string name="events_show_more">Show more</string>
|
||||
<string name="events_loading">Loading…</string>
|
||||
|
||||
<!-- ── Account → Blocked (USER_SAFETY.md §4.3; the web's BlockedPanel.jsx) ─ -->
|
||||
<string name="blocked_title">Blocked</string>
|
||||
<string name="blocked_row_hint">Players and members you blocked, and anything you hid for yourself.</string>
|
||||
<string name="blocked_open">Blocked players</string>
|
||||
<string name="blocked_subtitle">You don’t see anything a blocked member or player writes — forum posts and in-game chat — in the app or on the website. They aren’t told. Staff can’t be blocked.</string>
|
||||
<string name="blocked_empty">You haven’t blocked anyone.</string>
|
||||
<string name="blocked_unknown_player">Unknown player</string>
|
||||
<string name="blocked_kind_account">Account</string>
|
||||
<string name="blocked_kind_game">%1$s %2$s, not linked to an account</string>
|
||||
<string name="blocked_subtitle_when">%1$s · blocked %2$s</string>
|
||||
<string name="blocked_not_applied_staff">Not applied: this account is now a staff member, and staff can’t be blocked.</string>
|
||||
<string name="blocked_unblock">Unblock</string>
|
||||
<string name="blocked_unblocked">Unblocked.</string>
|
||||
<string name="blocked_unblocked_named">%1$s is unblocked.</string>
|
||||
<string name="blocked_unblock_failed">Could not unblock that.</string>
|
||||
<plurals name="blocked_hidden_count">
|
||||
<item quantity="one">You’ve hidden %d item for yourself.</item>
|
||||
<item quantity="other">You’ve hidden %d items for yourself.</item>
|
||||
</plurals>
|
||||
<string name="blocked_show_hidden">Show them again</string>
|
||||
<string name="blocked_hidden_cleared">Everything you hid is shown again.</string>
|
||||
<string name="blocked_hidden_failed">Could not show those again.</string>
|
||||
</resources>
|
||||
|
||||
@@ -154,4 +154,48 @@
|
||||
<string name="rust_backpack_page">Page %1$d</string>
|
||||
<string name="rust_reach_live">%1$s · has it</string>
|
||||
<string name="rust_reach_waiting">%1$s · waiting</string>
|
||||
|
||||
<!--
|
||||
A chat line's long-press menu: report, hide, block (docs/website/USER_SAFETY.md
|
||||
§4.3). The wording follows the website's ChatMenu.jsx, so a member reads one set
|
||||
of sentences in either place. Account → Blocked is core's, in strings.xml.
|
||||
-->
|
||||
<string name="safety_line_actions">Report, hide or block</string>
|
||||
<string name="safety_line_quote">%1$s: %2$s</string>
|
||||
<string name="safety_this_player">this player</string>
|
||||
<string name="safety_sign_in_hint">Sign in to report or block.</string>
|
||||
<string name="safety_sign_in">Sign in</string>
|
||||
<string name="safety_report">Report</string>
|
||||
<string name="safety_hide_for_me">Hide for me</string>
|
||||
<string name="safety_block_player">Block player</string>
|
||||
<string name="safety_hide_for_everyone">Hide for everyone</string>
|
||||
<string name="safety_show_to_everyone">Show to everyone again</string>
|
||||
<string name="safety_hidden_by_staff">Hidden by staff</string>
|
||||
<string name="safety_cancel">Cancel</string>
|
||||
<string name="safety_close">Close</string>
|
||||
|
||||
<string name="safety_report_explainer">This goes to site staff. Reporting does not hide or change anything — it asks a staffer to look.</string>
|
||||
<string name="safety_report_detail">Anything a staffer should know (optional)</string>
|
||||
<string name="safety_report_send">Send report</string>
|
||||
<string name="safety_reported">Reported to site staff.</string>
|
||||
<string name="safety_reason_abuse">Abusive or harassing</string>
|
||||
<string name="safety_reason_spam">Spam</string>
|
||||
<string name="safety_reason_sexual">Sexual content</string>
|
||||
<string name="safety_reason_illegal">Illegal content</string>
|
||||
<string name="safety_reason_impersonation">Impersonation</string>
|
||||
<string name="safety_reason_other">Something else</string>
|
||||
|
||||
<string name="safety_block_confirm">Block %1$s? You won’t see anything they write, here or on the website. They aren’t told. You can undo it from Account → Blocked.</string>
|
||||
<string name="safety_block">Block</string>
|
||||
|
||||
<string name="safety_error_already_reported">You have already reported this. Staff will look at it.</string>
|
||||
<string name="safety_error_already_blocked">You have already blocked this player.</string>
|
||||
<string name="safety_error_gone">That line is no longer there.</string>
|
||||
<string name="safety_error_staff">You can’t block a staff member.</string>
|
||||
<string name="safety_error_forbidden">Your account can’t do that.</string>
|
||||
<string name="safety_error_not_blockable">This player can’t be blocked.</string>
|
||||
<string name="safety_error_rate_limited">Too many reports. Please give staff a chance to look at the ones you have raised.</string>
|
||||
<string name="safety_error_unavailable">The game module did not answer. Try again in a moment.</string>
|
||||
<string name="safety_error_demo">This is turned off on the demo site.</string>
|
||||
<string name="safety_error_other">Could not do that. Try again.</string>
|
||||
</resources>
|
||||
|
||||
@@ -120,4 +120,17 @@ class FakeRustApi : RustApi {
|
||||
error?.let { throw it }
|
||||
return mapLiveQueue.removeFirstOrNull()?.invoke() ?: mapLive
|
||||
}
|
||||
|
||||
/** Every hide or unhide asked for, in order: `(lineId, hidden)`. */
|
||||
val chatHides: MutableList<Pair<Long, Boolean>> = mutableListOf()
|
||||
|
||||
override suspend fun hideChat(lineId: Long) {
|
||||
error?.let { throw it }
|
||||
chatHides += lineId to true
|
||||
}
|
||||
|
||||
override suspend fun unhideChat(lineId: Long) {
|
||||
error?.let { throw it }
|
||||
chatHides += lineId to false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.SafetyApi
|
||||
import com.runicgateway.app.data.api.dto.BlockListDto
|
||||
import com.runicgateway.app.data.api.dto.BlockResultDto
|
||||
import com.runicgateway.app.data.api.dto.HiddenItemDto
|
||||
import com.runicgateway.app.data.api.dto.HiddenListDto
|
||||
import com.runicgateway.app.data.api.dto.ReportRequest
|
||||
import com.runicgateway.app.data.api.dto.ReportResultDto
|
||||
import com.runicgateway.app.data.api.dto.SafetyOkDto
|
||||
import com.runicgateway.app.data.api.dto.SafetyTargetRequest
|
||||
import com.runicgateway.app.data.api.dto.UserBlockDto
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* A configurable fake of [SafetyApi] (USER_SAFETY.md §4.3). It keeps blocks and
|
||||
* hides the way the server does, so a test can block, read the list back and
|
||||
* unblock; set [error] to make the next call throw it instead.
|
||||
*/
|
||||
class FakeSafetyApi : SafetyApi {
|
||||
|
||||
/** Thrown by the next call, then cleared. */
|
||||
var error: Throwable? = null
|
||||
|
||||
val reports: MutableList<ReportRequest> = mutableListOf()
|
||||
val blockRequests: MutableList<SafetyTargetRequest> = mutableListOf()
|
||||
val hides: MutableList<SafetyTargetRequest> = mutableListOf()
|
||||
val blockList: MutableList<UserBlockDto> = mutableListOf()
|
||||
val hiddenList: MutableList<HiddenItemDto> = mutableListOf()
|
||||
var hiddenReads: Int = 0
|
||||
|
||||
private fun fail() {
|
||||
error?.let {
|
||||
error = null
|
||||
throw it
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun report(body: ReportRequest): ReportResultDto {
|
||||
fail()
|
||||
reports += body
|
||||
return ReportResultDto(ok = true, reportId = reports.size.toLong())
|
||||
}
|
||||
|
||||
override suspend fun blocks(): BlockListDto {
|
||||
fail()
|
||||
return BlockListDto(blockList.toList())
|
||||
}
|
||||
|
||||
override suspend fun block(body: SafetyTargetRequest): BlockResultDto {
|
||||
fail()
|
||||
blockRequests += body
|
||||
val block = UserBlockDto(id = (blockList.size + 1).toLong(), kind = "game", name = "Brannock")
|
||||
blockList += block
|
||||
return BlockResultDto(block)
|
||||
}
|
||||
|
||||
override suspend fun unblock(id: Long): SafetyOkDto {
|
||||
fail()
|
||||
if (!blockList.removeAll { it.id == id }) throw httpError(404)
|
||||
return SafetyOkDto(ok = true)
|
||||
}
|
||||
|
||||
override suspend fun hidden(): HiddenListDto {
|
||||
hiddenReads++
|
||||
fail()
|
||||
return HiddenListDto(hiddenList.toList())
|
||||
}
|
||||
|
||||
override suspend fun hide(body: SafetyTargetRequest): SafetyOkDto {
|
||||
fail()
|
||||
hides += body
|
||||
hiddenList += HiddenItemDto(body.targetType, body.targetId)
|
||||
return SafetyOkDto(ok = true)
|
||||
}
|
||||
|
||||
override suspend fun clearHidden(): SafetyOkDto {
|
||||
fail()
|
||||
val n = hiddenList.size
|
||||
hiddenList.clear()
|
||||
return SafetyOkDto(ok = true, cleared = n)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** A server refusal, with an optional JSON body. */
|
||||
fun httpError(status: Int, body: String = "{}"): HttpException =
|
||||
HttpException(Response.error<Any>(status, body.toResponseBody()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.data.api.dto.BlockListDto
|
||||
import com.runicgateway.app.data.api.dto.ReportRequest
|
||||
import com.runicgateway.app.data.api.fake.FakeSafetyApi
|
||||
import com.runicgateway.app.data.api.fake.FakeSafetyApi.Companion.httpError
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
/** Report, block and hide over core's routes (USER_SAFETY.md §4.1, §4.3). */
|
||||
class SafetyRepositoryTest {
|
||||
|
||||
private val api = FakeSafetyApi()
|
||||
private val repository = SafetyRepository(api)
|
||||
|
||||
/** The app's own wire settings (NetworkModule). */
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a report names the item and its reason, and a blank detail is left out`() = runTest {
|
||||
assertEquals(SafetyOutcome.Done, repository.report("rust.chat", 42, "spam", " "))
|
||||
|
||||
val sent = api.reports.single()
|
||||
assertEquals(ReportRequest("rust.chat", 42, "spam", null), sent)
|
||||
// The server's validator accepts an absent detail and refuses a null one.
|
||||
assertEquals("""{"targetType":"rust.chat","targetId":42,"reason":"spam"}""", json.encodeToString(sent))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a report's detail is trimmed`() = runTest {
|
||||
repository.report("rust.chat", 42, "abuse", " said it twice \n")
|
||||
assertEquals("said it twice", api.reports.single().detail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block names the item, never a person`() = runTest {
|
||||
assertEquals(SafetyOutcome.Done, repository.blockAuthorOf("rust.chat", 7))
|
||||
assertEquals("rust.chat", api.blockRequests.single().targetType)
|
||||
assertEquals(7L, api.blockRequests.single().targetId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 403 on a block is staff, and anywhere else it is forbidden`() = runTest {
|
||||
api.error = httpError(403, """{"code":"staff","message":"You can’t block a staff member."}""")
|
||||
assertEquals(SafetyOutcome.Refused(SafetyRefusal.STAFF), repository.blockAuthorOf("rust.chat", 7))
|
||||
|
||||
api.error = httpError(403)
|
||||
assertEquals(SafetyOutcome.Refused(SafetyRefusal.FORBIDDEN), repository.hide("rust.chat", 7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the demo's refusal is told apart from a staff refusal`() = runTest {
|
||||
api.error = httpError(403, """{"code":"DEMO_LOCKED","message":"This is turned off on the demo site."}""")
|
||||
assertEquals(SafetyOutcome.Refused(SafetyRefusal.DEMO_LOCKED), repository.blockAuthorOf("rust.chat", 7))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each status the server sends becomes its own refusal`() = runTest {
|
||||
val cases = listOf(
|
||||
409 to SafetyRefusal.ALREADY_DONE,
|
||||
404 to SafetyRefusal.GONE,
|
||||
429 to SafetyRefusal.RATE_LIMITED,
|
||||
503 to SafetyRefusal.UNAVAILABLE,
|
||||
500 to SafetyRefusal.OTHER,
|
||||
)
|
||||
cases.forEach { (status, refusal) ->
|
||||
api.error = httpError(status)
|
||||
assertEquals("status $status", SafetyOutcome.Refused(refusal), repository.report("rust.chat", 1, "spam", null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 400 on a block is not blockable, and on a report it is not`() = runTest {
|
||||
api.error = httpError(400)
|
||||
assertEquals(SafetyOutcome.Refused(SafetyRefusal.NOT_BLOCKABLE), repository.blockAuthorOf("rust.chat", 1))
|
||||
api.error = httpError(400)
|
||||
assertEquals(SafetyOutcome.Refused(SafetyRefusal.OTHER), repository.report("rust.chat", 1, "spam", null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no answer at all is a network error`() = runTest {
|
||||
api.error = IOException("offline")
|
||||
assertEquals(SafetyOutcome.NetworkError, repository.hide("rust.chat", 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block list decodes both kinds, and a block that stopped applying says why`() {
|
||||
val body = """
|
||||
{"blocks":[
|
||||
{"id":1,"kind":"account","name":"rowan","identity":null,"applies":true,"reason":null,
|
||||
"createdAt":"2026-10-30T12:00:00.000Z"},
|
||||
{"id":2,"kind":"game","name":"Brannock","applies":true,"reason":null,
|
||||
"identity":{"module":"rust","kind":"steam","label":"Steam","id":"12345678900000001"}},
|
||||
{"id":3,"kind":"account","name":"modsy","applies":false,"reason":"staff"}
|
||||
]}
|
||||
""".trimIndent()
|
||||
|
||||
val blocks = json.decodeFromString<BlockListDto>(body).blocks
|
||||
|
||||
assertNull(blocks[0].identity)
|
||||
assertEquals("Steam", blocks[1].identity?.label)
|
||||
assertEquals("12345678900000001", blocks[1].identity?.id)
|
||||
assertFalse(blocks[2].applies)
|
||||
assertEquals("staff", blocks[2].reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.HiddenItemDto
|
||||
import com.runicgateway.app.data.api.dto.UserBlockDto
|
||||
import com.runicgateway.app.data.api.fake.FakeSafetyApi
|
||||
import com.runicgateway.app.data.repository.SafetyRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
import java.time.ZoneId
|
||||
|
||||
/** Account → Blocked (USER_SAFETY.md §4.3). */
|
||||
class BlockedViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val api = FakeSafetyApi()
|
||||
|
||||
private fun viewModel() = BlockedViewModel(SafetyRepository(api))
|
||||
|
||||
@Test
|
||||
fun `it lists the blocks and counts the hidden items`() {
|
||||
api.blockList += UserBlockDto(id = 1, name = "rowan")
|
||||
api.hiddenList += HiddenItemDto("rust.chat", 4)
|
||||
api.hiddenList += HiddenItemDto("team_forum_post", 9)
|
||||
|
||||
val state = viewModel().state.value
|
||||
|
||||
assertEquals(listOf("rowan"), (state.blocks as UiState.Success).data.map { it.name })
|
||||
assertEquals(2, state.hiddenCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unblocking takes the row away and names who`() {
|
||||
api.blockList += UserBlockDto(id = 1, name = "rowan")
|
||||
val vm = viewModel()
|
||||
|
||||
vm.unblock(UserBlockDto(id = 1, name = "rowan"))
|
||||
|
||||
assertTrue((vm.state.value.blocks as UiState.Success).data.isEmpty())
|
||||
val fb = vm.state.value.feedback!!
|
||||
assertTrue(fb.ok)
|
||||
assertEquals(R.string.blocked_unblocked_named, fb.messageRes)
|
||||
assertEquals("rowan", fb.arg)
|
||||
assertFalse(vm.state.value.busy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block that is already gone says it could not unblock`() {
|
||||
val vm = viewModel()
|
||||
vm.unblock(UserBlockDto(id = 99, name = "ghost"))
|
||||
|
||||
assertEquals(R.string.blocked_unblock_failed, vm.state.value.feedback!!.messageRes)
|
||||
assertFalse(vm.state.value.feedback!!.ok)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `show them again clears every hide`() {
|
||||
api.hiddenList += HiddenItemDto("rust.chat", 4)
|
||||
val vm = viewModel()
|
||||
|
||||
vm.showHidden()
|
||||
|
||||
assertEquals(0, vm.state.value.hiddenCount)
|
||||
assertEquals(R.string.blocked_hidden_cleared, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed read of the blocks is an error to retry`() {
|
||||
api.error = IOException("offline")
|
||||
val vm = viewModel()
|
||||
assertTrue(vm.state.value.blocks is UiState.Error)
|
||||
|
||||
vm.load()
|
||||
assertTrue(vm.state.value.blocks is UiState.Success)
|
||||
assertNull(vm.state.value.feedback)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block's date is read in the reader's zone, and a missing one is none`() {
|
||||
assertEquals(blockedOn("2026-10-30 23:30:00", ZoneId.of("UTC")), blockedOn("2026-10-30T23:30:00Z", ZoneId.of("UTC")))
|
||||
assertTrue(blockedOn("2026-10-30T23:30:00Z", ZoneId.of("UTC"))!!.contains("30"))
|
||||
assertTrue(blockedOn("2026-10-30T23:30:00Z", ZoneId.of("Asia/Tokyo"))!!.contains("31"))
|
||||
assertNull(blockedOn(null))
|
||||
assertNull(blockedOn("not a date"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.core.auth.Role
|
||||
import com.runicgateway.app.core.auth.SessionUser
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
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
|
||||
|
||||
/** What a chat line's menu offers (USER_SAFETY.md §4.3, S6, S8, S10, S13). */
|
||||
class ChatSafetyTest {
|
||||
|
||||
private fun chat(canBlock: Boolean? = null, hidden: Boolean = false) =
|
||||
RustEventDto(id = 1, kind = "player.chat", canBlock = canBlock, hidden = hidden)
|
||||
|
||||
private fun user(role: Role) = SessionUser(id = 9, username = "reader", role = role)
|
||||
|
||||
@Test
|
||||
fun `a row that is not a chat line has no menu`() {
|
||||
assertNull(chatActions(RustEventDto(kind = "player.death"), user(Role.PLAYER)))
|
||||
assertNull(chatActions(RustEventDto(kind = "player.death"), null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signed out, the menu only says to sign in`() {
|
||||
val actions = chatActions(chat(canBlock = true), null)!!
|
||||
assertTrue(actions.signIn)
|
||||
assertFalse(actions.report || actions.hide || actions.block)
|
||||
assertNull(actions.staff)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a member reports and hides any line, and blocks only where the server said so`() {
|
||||
val yes = chatActions(chat(canBlock = true), user(Role.PLAYER))!!
|
||||
assertTrue(yes.report && yes.hide && yes.block)
|
||||
assertNull(yes.staff)
|
||||
|
||||
assertFalse(chatActions(chat(canBlock = false), user(Role.PLAYER))!!.block)
|
||||
// Absent is "not said", and not saying is not permission.
|
||||
assertFalse(chatActions(chat(canBlock = null), user(Role.PLAYER))!!.block)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `admins and moderators hide for everyone, and an editor does not`() {
|
||||
assertEquals(StaffHide.HIDE, chatActions(chat(), user(Role.MODERATOR))!!.staff)
|
||||
assertEquals(StaffHide.HIDE, chatActions(chat(), user(Role.ADMIN))!!.staff)
|
||||
assertNull(chatActions(chat(), user(Role.EDITOR))!!.staff)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on a line staff hid, staff are offered to show it again`() {
|
||||
assertEquals(StaffHide.UNHIDE, chatActions(chat(hidden = true), user(Role.MODERATOR))!!.staff)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a feed row carries canBlock and hidden beside its frame`() {
|
||||
val json = Json { ignoreUnknownKeys = true }
|
||||
val row = json.decodeFromString<RustEventDto>(
|
||||
"""{"id":5,"kind":"player.chat","t":1,"steamId":"76561198000000001",
|
||||
"frame":{"name":"Brannock","message":"hi"},"canBlock":true,"hidden":true}""",
|
||||
)
|
||||
assertEquals(true, row.canBlock)
|
||||
assertTrue(row.hidden)
|
||||
|
||||
val signedOut = json.decodeFromString<RustEventDto>("""{"id":5,"kind":"player.chat","frame":{}}""")
|
||||
assertNull(signedOut.canBlock)
|
||||
assertFalse(signedOut.hidden)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.StoredSession
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.fake.FakeRustApi
|
||||
import com.runicgateway.app.data.api.fake.FakeSafetyApi
|
||||
import com.runicgateway.app.data.api.fake.FakeSafetyApi.Companion.httpError
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.data.repository.SafetyRepository
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A chat line's menu (USER_SAFETY.md §4.3): what each action calls, and that the
|
||||
* feed is re-read after anything that changes it — and not after a report.
|
||||
*/
|
||||
class ChatSafetyViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = MainDispatcherRule()
|
||||
|
||||
private class Store(var stored: StoredSession?) : TokenStore {
|
||||
override fun load(): StoredSession? = stored
|
||||
override fun save(session: StoredSession) { stored = session }
|
||||
override fun clear() { stored = null }
|
||||
}
|
||||
|
||||
private val safetyApi = FakeSafetyApi()
|
||||
private val rustApi = FakeRustApi()
|
||||
|
||||
private fun viewModel(role: String? = "player") = ChatSafetyViewModel(
|
||||
SafetyRepository(safetyApi),
|
||||
RustRepository(rustApi),
|
||||
SessionManager(Store(role?.let { StoredSession("a", "r", 9, "reader", it) })),
|
||||
)
|
||||
|
||||
private val line = RustEventDto(
|
||||
id = 42,
|
||||
kind = "player.chat",
|
||||
canBlock = true,
|
||||
frame = buildJsonObject {
|
||||
put("name", JsonPrimitive("Brannock"))
|
||||
put("message", JsonPrimitive("see you in september"))
|
||||
},
|
||||
)
|
||||
|
||||
/** Whether the view model asked for the feed to be re-read (without waiting forever). */
|
||||
private suspend fun ChatSafetyViewModel.askedForFeed(): Boolean =
|
||||
withTimeoutOrNull(1_000) { changes.first() } != null
|
||||
|
||||
@Test
|
||||
fun `hide for me hides that line, closes the menu and re-reads the feed`() = runTest {
|
||||
val vm = viewModel()
|
||||
vm.open(line)
|
||||
vm.hideForMe()
|
||||
|
||||
assertEquals("rust.chat", safetyApi.hides.single().targetType)
|
||||
assertEquals(42L, safetyApi.hides.single().targetId)
|
||||
assertNull(vm.menu.value.line)
|
||||
assertTrue(vm.askedForFeed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a report says it was sent and does not re-read the feed`() = runTest {
|
||||
val vm = viewModel()
|
||||
vm.open(line)
|
||||
vm.go(ChatMenuStep.REPORT)
|
||||
vm.setReason("abuse")
|
||||
vm.setDetail("again")
|
||||
vm.report()
|
||||
|
||||
val sent = safetyApi.reports.single()
|
||||
assertEquals("abuse", sent.reason)
|
||||
assertEquals("again", sent.detail)
|
||||
assertEquals(ChatMenuStep.DONE, vm.menu.value.step)
|
||||
assertEquals(R.string.safety_reported, vm.menu.value.doneRes)
|
||||
assertTrue(!vm.askedForFeed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused report stays on the form with words for why`() = runTest {
|
||||
val vm = viewModel()
|
||||
vm.open(line)
|
||||
vm.go(ChatMenuStep.REPORT)
|
||||
safetyApi.error = httpError(409)
|
||||
vm.report()
|
||||
|
||||
assertEquals(ChatMenuStep.REPORT, vm.menu.value.step)
|
||||
assertEquals(R.string.safety_error_already_reported, vm.menu.value.errorRes)
|
||||
|
||||
// Moving between steps drops the old refusal.
|
||||
vm.go(ChatMenuStep.MENU)
|
||||
assertNull(vm.menu.value.errorRes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blocking staff is refused in the block's own words`() = runTest {
|
||||
val vm = viewModel()
|
||||
vm.open(line)
|
||||
vm.go(ChatMenuStep.BLOCK)
|
||||
safetyApi.error = httpError(403)
|
||||
vm.block()
|
||||
|
||||
assertEquals(R.string.safety_error_staff, vm.menu.value.errorRes)
|
||||
assertNotNull(vm.menu.value.line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a block re-reads the feed`() = runTest {
|
||||
val vm = viewModel()
|
||||
vm.open(line)
|
||||
vm.block()
|
||||
|
||||
assertEquals(42L, safetyApi.blockRequests.single().targetId)
|
||||
assertTrue(vm.askedForFeed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `staff hide and unhide through the module's own route`() = runTest {
|
||||
val vm = viewModel(role = "moderator")
|
||||
vm.open(line)
|
||||
vm.staffHide(true)
|
||||
vm.open(line.copy(hidden = true))
|
||||
vm.staffHide(false)
|
||||
|
||||
assertEquals(listOf(42L to true, 42L to false), rustApi.chatHides)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row that is not a chat line opens nothing`() = runTest {
|
||||
val vm = viewModel()
|
||||
vm.open(RustEventDto(id = 3, kind = "player.death"))
|
||||
assertNull(vm.menu.value.line)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signed out, the menu opens to say sign in and nothing else`() = runTest {
|
||||
val vm = viewModel(role = null)
|
||||
vm.open(line)
|
||||
assertNotNull(vm.menu.value.line)
|
||||
assertTrue(chatActions(line, vm.user)!!.signIn)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user