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) + } +}