From 5386889ef4999eddd1475c005213890d3afbdcc4 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 30 Oct 2026 09:56:25 -0500 Subject: [PATCH] feat(safety): report, block and hide chat lines in the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit USER_SAFETY.md step 4 (§4.3): the app half of report, block and hide. - Long-press a Rust chat line for its menu: Report (the six reasons and an optional detail), Hide for me, Block player where the line says canBlock, and for admins and moderators Hide for everyone, or Show to everyone again on a hidden line. Signed out, it says Sign in to report or block and opens the login screen (S6). - The feed is re-read after a hide, a block or a staff hide, never edited in the app: the server filters it per reader (S11). A report changes nothing and does not re-read. - Staff see a line hidden for everyone dimmed and marked Hidden by staff (S10). - Account -> Blocked lists account and Steam-ID blocks with Unblock, says when a block no longer applies because its target is now staff (S8), and offers Show them again for hidden items. - Core's routes on a new SafetyApi; the module's two staff routes on RustApi. RustEventDto gains canBlock and hidden. Two fixes from the emulator walk: Back on the report form or the block confirmation now steps back to the menu instead of closing the sheet (Back to put the keyboard away was throwing a half-written report away), and the sheet opens fully so Send report is on screen. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .../com/runicgateway/app/data/api/RustApi.kt | 15 ++ .../runicgateway/app/data/api/SafetyApi.kt | 65 ++++++ .../runicgateway/app/data/api/dto/RustDto.kt | 7 + .../app/data/api/dto/SafetyDto.kt | 103 +++++++++ .../app/data/repository/RustRepository.kt | 8 + .../app/data/repository/SafetyRepository.kt | 110 +++++++++ .../com/runicgateway/app/di/NetworkModule.kt | 6 + .../java/com/runicgateway/app/ui/RunicApp.kt | 10 + .../runicgateway/app/ui/auth/AccountScreen.kt | 24 ++ .../runicgateway/app/ui/auth/BlockedScreen.kt | 156 +++++++++++++ .../app/ui/auth/BlockedViewModel.kt | 116 ++++++++++ .../runicgateway/app/ui/navigation/Routes.kt | 3 + .../runicgateway/app/ui/rust/ChatSafety.kt | 72 ++++++ .../app/ui/rust/ChatSafetySheet.kt | 209 ++++++++++++++++++ .../app/ui/rust/ChatSafetyViewModel.kt | 173 +++++++++++++++ .../app/ui/rust/RustServerScreen.kt | 65 +++++- .../app/ui/rust/RustServerViewModel.kt | 9 + app/src/main/res/values/strings.xml | 23 ++ app/src/main/res/values/strings_rust.xml | 44 ++++ .../app/data/api/fake/FakeRustApi.kt | 13 ++ .../app/data/api/fake/FakeSafetyApi.kt | 94 ++++++++ .../data/repository/SafetyRepositoryTest.kt | 118 ++++++++++ .../app/ui/auth/BlockedViewModelTest.kt | 98 ++++++++ .../app/ui/rust/ChatSafetyTest.kt | 75 +++++++ .../app/ui/rust/ChatSafetyViewModelTest.kt | 159 +++++++++++++ 25 files changed, 1770 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/data/api/SafetyApi.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/SafetyDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/SafetyRepository.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/BlockedScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/auth/BlockedViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/ChatSafety.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetySheet.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetyViewModel.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/fake/FakeSafetyApi.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/repository/SafetyRepositoryTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/auth/BlockedViewModelTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyViewModelTest.kt diff --git a/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt b/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt index 987c566..122ce1b 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/RustApi.kt @@ -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 } diff --git a/app/src/main/java/com/runicgateway/app/data/api/SafetyApi.kt b/app/src/main/java/com/runicgateway/app/data/api/SafetyApi.kt new file mode 100644 index 0000000..5a3a831 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/SafetyApi.kt @@ -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 +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt index dc63720..d4208f7 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt @@ -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. diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/SafetyDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/SafetyDto.kt new file mode 100644 index 0000000..5f73375 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/SafetyDto.kt @@ -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: + * }`. + * + * 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 = 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 = 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, +) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt index 7bd5779..93cd3c9 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/RustRepository.kt @@ -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 = 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 = safeApiCall { + if (hidden) api.hideChat(lineId) else api.unhideChat(lineId) + } } diff --git a/app/src/main/java/com/runicgateway/app/data/repository/SafetyRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/SafetyRepository.kt new file mode 100644 index 0000000..fdb2a0c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/SafetyRepository.kt @@ -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> = safeApiCall { api.blocks() }.map { it.blocks } + + suspend fun unblock(id: Long): SafetyOutcome = outcome(blocking = false) { api.unblock(id) } + + suspend fun hidden(): ApiResult> = 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 + }, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt index 6b721c4..3f0e3db 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -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 diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 3ea35a5..a391b4f 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -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() diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt index ca681c8..90118b0 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/AccountScreen.kt @@ -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()) { diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/BlockedScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/BlockedScreen.kt new file mode 100644 index 0000000..0c47afa --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/BlockedScreen.kt @@ -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) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/BlockedViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/BlockedViewModel.kt new file mode 100644 index 0000000..acbe9e6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/auth/BlockedViewModel.kt @@ -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> = 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.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) + } diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index 4de6862..ae93352 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -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. * diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafety.kt b/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafety.kt new file mode 100644 index 0000000..162514c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafety.kt @@ -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 = 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, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetySheet.kt b/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetySheet.kt new file mode 100644 index 0000000..2fc9dfe --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetySheet.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetyViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetyViewModel.kt new file mode 100644 index 0000000..c1ec2ee --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/rust/ChatSafetyViewModel.kt @@ -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 = _menu.asStateFlow() + + private val _changes = Channel(Channel.CONFLATED) + + /** One item per change to what this reader's feed should show. */ + val changes: Flow = _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 +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt index 5552948..9dbd1f1 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt @@ -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 ─────────────────────────────────────────────────────────── /** diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt index 6480d03..a58571c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerViewModel.kt @@ -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() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8784c26..a85977c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -376,4 +376,27 @@ Score %1$s Show more Loading… + + + Blocked + Players and members you blocked, and anything you hid for yourself. + Blocked players + 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. + You haven’t blocked anyone. + Unknown player + Account + %1$s %2$s, not linked to an account + %1$s · blocked %2$s + Not applied: this account is now a staff member, and staff can’t be blocked. + Unblock + Unblocked. + %1$s is unblocked. + Could not unblock that. + + You’ve hidden %d item for yourself. + You’ve hidden %d items for yourself. + + Show them again + Everything you hid is shown again. + Could not show those again. diff --git a/app/src/main/res/values/strings_rust.xml b/app/src/main/res/values/strings_rust.xml index 6f92576..d8ee48f 100644 --- a/app/src/main/res/values/strings_rust.xml +++ b/app/src/main/res/values/strings_rust.xml @@ -154,4 +154,48 @@ Page %1$d %1$s · has it %1$s · waiting + + + Report, hide or block + %1$s: %2$s + this player + Sign in to report or block. + Sign in + Report + Hide for me + Block player + Hide for everyone + Show to everyone again + Hidden by staff + Cancel + Close + + This goes to site staff. Reporting does not hide or change anything — it asks a staffer to look. + Anything a staffer should know (optional) + Send report + Reported to site staff. + Abusive or harassing + Spam + Sexual content + Illegal content + Impersonation + Something else + + 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. + Block + + You have already reported this. Staff will look at it. + You have already blocked this player. + That line is no longer there. + You can’t block a staff member. + Your account can’t do that. + This player can’t be blocked. + Too many reports. Please give staff a chance to look at the ones you have raised. + The game module did not answer. Try again in a moment. + This is turned off on the demo site. + Could not do that. Try again. diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt index 65b03df..fc66b87 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeRustApi.kt @@ -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> = 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 + } } diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeSafetyApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeSafetyApi.kt new file mode 100644 index 0000000..225f605 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeSafetyApi.kt @@ -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 = mutableListOf() + val blockRequests: MutableList = mutableListOf() + val hides: MutableList = mutableListOf() + val blockList: MutableList = mutableListOf() + val hiddenList: MutableList = 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(status, body.toResponseBody())) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/repository/SafetyRepositoryTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/SafetyRepositoryTest.kt new file mode 100644 index 0000000..f708f30 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/repository/SafetyRepositoryTest.kt @@ -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(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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/auth/BlockedViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/auth/BlockedViewModelTest.kt new file mode 100644 index 0000000..c2b248a --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/auth/BlockedViewModelTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyTest.kt new file mode 100644 index 0000000..4c9ba08 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyTest.kt @@ -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( + """{"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("""{"id":5,"kind":"player.chat","frame":{}}""") + assertNull(signedOut.canBlock) + assertFalse(signedOut.hidden) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyViewModelTest.kt new file mode 100644 index 0000000..5a8d308 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/rust/ChatSafetyViewModelTest.kt @@ -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) + } +} -- 2.49.1