diff --git a/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt b/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt index 8a32101..775af9b 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt @@ -5,10 +5,16 @@ package com.runicgateway.app.data.api import com.runicgateway.app.data.api.dto.AdminDashboardDto import com.runicgateway.app.data.api.dto.AdminPostDto +import com.runicgateway.app.data.api.dto.BanRequest +import com.runicgateway.app.data.api.dto.BroadcastRequest +import com.runicgateway.app.data.api.dto.KickRequest +import com.runicgateway.app.data.api.dto.PageRespondRequest import com.runicgateway.app.data.api.dto.PostCreateRequest import com.runicgateway.app.data.api.dto.PublishRequest import com.runicgateway.app.data.api.dto.SiteModeRequest import com.runicgateway.app.data.api.dto.SiteModeStateDto +import com.runicgateway.app.data.api.dto.SupportPageDto +import com.runicgateway.app.data.api.dto.UnbanRequest import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto import com.runicgateway.app.data.api.dto.WikiCategoryRequest import com.runicgateway.app.data.api.dto.AdminWikiTagDto @@ -65,4 +71,27 @@ interface AdminApi { @GET("api/v1/admin/wiki/tags") suspend fun wikiTags(): List + + // ── Moderation: shard write plane (admin/moderator) ─────────────────── + @POST("api/v1/admin/shard/kick") + suspend fun kick(@Body body: KickRequest): Response + + @POST("api/v1/admin/shard/ban") + suspend fun ban(@Body body: BanRequest): Response + + @POST("api/v1/admin/shard/unban") + suspend fun unban(@Body body: UnbanRequest): Response + + @POST("api/v1/admin/shard/broadcast") + suspend fun broadcast(@Body body: BroadcastRequest): Response + + // ── Support queue: help pages (admin/moderator) ─────────────────────── + @GET("api/v1/admin/shard/pages") + suspend fun supportPages(): List + + @POST("api/v1/admin/shard/pages/{id}/respond") + suspend fun respondPage(@Path("id") id: String, @Body body: PageRespondRequest): Response + + @POST("api/v1/admin/shard/pages/{id}/close") + suspend fun closePage(@Path("id") id: String): Response } diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt index d0c0fa2..7646246 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt @@ -126,3 +126,54 @@ data class AdminWikiTagDto( val label: String = "", @SerialName("published_count") val publishedCount: Int? = null, ) + +// ── Moderation (admin/moderator; shard write plane) ─────────────────────── + +/** `POST /admin/shard/kick` — at least one of account/serial. */ +@Serializable +data class KickRequest(val account: String? = null, val serial: String? = null) + +/** `POST /admin/shard/ban` — account/serial + optional duration (0/absent = indefinite). */ +@Serializable +data class BanRequest( + val account: String? = null, + val serial: String? = null, + @SerialName("durationSec") val durationSec: Long? = null, + val reason: String? = null, +) + +/** `POST /admin/shard/unban`. */ +@Serializable +data class UnbanRequest(val account: String) + +/** `POST /admin/shard/broadcast` — a system message to everyone online. */ +@Serializable +data class BroadcastRequest(val text: String, val hue: Int? = null) + +// ── Support queue (admin/moderator; help pages) ─────────────────────────── + +/** + * One open help page from `GET /admin/shard/pages` (INTEGRATION.md §4). `pageId` + * is the sender's in-game serial (the `:id` for respond/close). Permissive — the + * shard-state fields beyond these (coords, timing) are ignored. + */ +@Serializable +data class SupportPageDto( + @SerialName("pageId") val pageId: String = "", + val type: String? = null, + val message: String? = null, + val handled: Boolean? = null, + val handler: String? = null, + val sender: SupportActorDto? = null, +) + +/** The page's sender (actor object); [account] present when the character is linked. */ +@Serializable +data class SupportActorDto( + val name: String? = null, + val account: String? = null, +) + +/** `POST /admin/shard/pages/:id/respond` — reply, optionally closing the page. */ +@Serializable +data class PageRespondRequest(val message: String, val close: Boolean = false) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt index cefd3e8..255cf77 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt @@ -8,10 +8,16 @@ import com.runicgateway.app.core.result.safeApiCall import com.runicgateway.app.data.api.AdminApi import com.runicgateway.app.data.api.dto.AdminDashboardDto import com.runicgateway.app.data.api.dto.AdminPostDto +import com.runicgateway.app.data.api.dto.BanRequest +import com.runicgateway.app.data.api.dto.BroadcastRequest +import com.runicgateway.app.data.api.dto.KickRequest +import com.runicgateway.app.data.api.dto.PageRespondRequest import com.runicgateway.app.data.api.dto.PostCreateRequest import com.runicgateway.app.data.api.dto.PublishRequest import com.runicgateway.app.data.api.dto.SiteModeRequest import com.runicgateway.app.data.api.dto.SiteModeStateDto +import com.runicgateway.app.data.api.dto.SupportPageDto +import com.runicgateway.app.data.api.dto.UnbanRequest import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto import com.runicgateway.app.data.api.dto.WikiCategoryRequest import com.runicgateway.app.data.api.dto.AdminWikiTagDto @@ -58,6 +64,28 @@ class AdminRepository @Inject constructor( suspend fun wikiTags(): ApiResult> = safeApiCall { api.wikiTags() } + // ── Moderation: shard write plane ───────────────────────────────────── + suspend fun kick(account: String?, serial: String?): ApiResult = + safeApiCall { api.kick(KickRequest(account, serial)).requireOk() } + + suspend fun ban(account: String?, serial: String?, durationSec: Long?, reason: String?): ApiResult = + safeApiCall { api.ban(BanRequest(account, serial, durationSec, reason)).requireOk() } + + suspend fun unban(account: String): ApiResult = + safeApiCall { api.unban(UnbanRequest(account)).requireOk() } + + suspend fun broadcast(text: String, hue: Int?): ApiResult = + safeApiCall { api.broadcast(BroadcastRequest(text, hue)).requireOk() } + + // ── Support queue: help pages ───────────────────────────────────────── + suspend fun supportPages(): ApiResult> = safeApiCall { api.supportPages() } + + suspend fun respondPage(id: String, message: String, close: Boolean): ApiResult = + safeApiCall { api.respondPage(id, PageRespondRequest(message, close)).requireOk() } + + suspend fun closePage(id: String): ApiResult = + safeApiCall { api.closePage(id).requireOk() } + /** Turn a bodyless [Response] into a thrown [HttpException] on a non-2xx, so * [safeApiCall] can fold it into an [ApiResult.HttpError] like every other call. */ private fun Response.requireOk() { 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 95fa12d..43afa1c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -61,6 +61,8 @@ import com.runicgateway.app.ui.news.NewsScreen import com.runicgateway.app.ui.news.PostScreen import com.runicgateway.app.ui.admin.AdminContentScreen import com.runicgateway.app.ui.admin.AdminDashboardScreen +import com.runicgateway.app.ui.admin.AdminModerationScreen +import com.runicgateway.app.ui.admin.AdminSupportScreen import com.runicgateway.app.ui.notifications.NotificationsScreen import com.runicgateway.app.ui.page.PageScreen import com.runicgateway.app.ui.player.CharacterSheetScreen @@ -83,7 +85,7 @@ private val TOP_LEVEL_ROUTES = setOf( Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT, Routes.NOTIFICATIONS, Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES, - Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, + Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT, ) /** @@ -376,6 +378,12 @@ private fun RunicNavHost( composable(Routes.ADMIN_CONTENT) { StaffGate(session, navController) { AdminContentScreen() } } + composable(Routes.ADMIN_MODERATION) { + StaffGate(session, navController, require = { it.isModerator }) { AdminModerationScreen() } + } + composable(Routes.ADMIN_SUPPORT) { + StaffGate(session, navController, require = { it.isModerator }) { AdminSupportScreen() } + } } } @@ -407,10 +415,11 @@ private fun PlayerGate( private fun StaffGate( session: Session, navController: NavHostController, + require: (com.runicgateway.app.core.auth.SessionUser) -> Boolean = { it.isStaff }, content: @Composable () -> Unit, ) { - val isStaff = (session as? Session.SignedIn)?.user?.isStaff == true - if (isStaff) content() else LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } + val ok = (session as? Session.SignedIn)?.user?.let(require) == true + if (ok) content() else LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) } } /** Navigate to a top-level menu destination: single instance, reset to it. */ diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationScreen.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationScreen.kt new file mode 100644 index 0000000..9261d6f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationScreen.kt @@ -0,0 +1,92 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.admin + +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.fillMaxSize +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.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +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.ui.components.SectionLabel + +/** + * The moderation screen (PLAN.md §1, M10): kick / ban / unban an account and + * broadcast, over `/admin/shard/…` (admin/moderator). A live sidecar is required; + * offline, actions return a clean "shard offline" message. Fields are entered here; + * the [AdminModerationViewModel] performs the guarded action. + */ +@Composable +fun AdminModerationScreen( + modifier: Modifier = Modifier, + viewModel: AdminModerationViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var account by rememberSaveable { mutableStateOf("") } + var serial by rememberSaveable { mutableStateOf("") } + var reason by rememberSaveable { mutableStateOf("") } + var duration by rememberSaveable { mutableStateOf("") } + var broadcast by rememberSaveable { mutableStateOf("") } + val busy = state.busy + + Column( + modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp), + ) { + state.feedback?.let { + Text( + text = stringResource(it.messageRes), + style = MaterialTheme.typography.bodySmall, + color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) + } + + // ── Account actions ────────────────────────────────────────────── + SectionLabel(stringResource(R.string.admin_mod_account_action)) + OutlinedTextField(value = account, onValueChange = { account = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_account)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + OutlinedTextField(value = serial, onValueChange = { serial = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_serial)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + OutlinedTextField(value = reason, onValueChange = { reason = it }, label = { Text(stringResource(R.string.admin_mod_reason)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + OutlinedTextField(value = duration, onValueChange = { duration = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_duration)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + + Row(Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { viewModel.kick(account, serial) }, enabled = !busy, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.admin_mod_kick)) + } + Button(onClick = { viewModel.ban(account, serial, duration.toLongOrNull(), reason) }, enabled = !busy, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.admin_mod_ban)) + } + OutlinedButton(onClick = { viewModel.unban(account) }, enabled = !busy, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.admin_mod_unban)) + } + } + + // ── Broadcast ──────────────────────────────────────────────────── + Spacer(Modifier.height(24.dp)) + SectionLabel(stringResource(R.string.admin_mod_broadcast_section)) + OutlinedTextField(value = broadcast, onValueChange = { broadcast = it }, label = { Text(stringResource(R.string.admin_mod_broadcast_text)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + Button(onClick = { viewModel.broadcast(broadcast, null) }, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { + Text(stringResource(R.string.admin_mod_broadcast)) + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt new file mode 100644 index 0000000..4ff7180 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt @@ -0,0 +1,89 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.admin + +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.data.repository.AdminRepository +import dagger.hilt.android.lifecycle.HiltViewModel +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 javax.inject.Inject + +/** + * Drives the moderation actions (PLAN.md §1, M10): kick / ban / unban an account + * and broadcast a system message, over the shard write plane (`/admin/shard/…`, + * admin/moderator). These need a live sidecar — when the shard is offline the call + * fails and the screen shows a clean error, never a crash (§7). The form fields live + * in the screen; this VM owns only the busy + feedback state and the actions. + */ +@HiltViewModel +class AdminModerationViewModel @Inject constructor( + private val admin: AdminRepository, +) : ViewModel() { + + data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int) + + data class State(val busy: Boolean = false, val feedback: Feedback? = null) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + fun clearFeedback() = _state.update { it.copy(feedback = null) } + + fun kick(account: String, serial: String) { + if (account.isBlank() && serial.isBlank()) return badTarget() + run(R.string.admin_mod_kicked) { admin.kick(account.ifBlank { null }, serial.ifBlank { null }) } + } + + fun ban(account: String, serial: String, durationSec: Long?, reason: String) { + if (account.isBlank() && serial.isBlank()) return badTarget() + run(R.string.admin_mod_banned) { + admin.ban(account.ifBlank { null }, serial.ifBlank { null }, durationSec, reason.ifBlank { null }) + } + } + + fun unban(account: String) { + if (account.isBlank()) return badTarget() + run(R.string.admin_mod_unbanned) { admin.unban(account.trim()) } + } + + fun broadcast(text: String, hue: Int?) { + if (text.isBlank()) { + _state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_text_required)) } + return + } + run(R.string.admin_mod_broadcasted) { admin.broadcast(text.trim(), hue) } + } + + private fun badTarget() { + _state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_target_required)) } + } + + private fun run(@StringRes okRes: Int, block: suspend () -> ApiResult) { + if (_state.value.busy) return + _state.update { it.copy(busy = true, feedback = null) } + viewModelScope.launch { + val feedback = when (val r = block()) { + is ApiResult.Ok -> Feedback(true, okRes) + is ApiResult.HttpError -> Feedback( + false, + when (r.status) { + 403 -> R.string.admin_forbidden + 503 -> R.string.admin_mod_shard_offline + else -> R.string.admin_action_failed + }, + ) + is ApiResult.NetworkError -> Feedback(false, R.string.error_network) + } + _state.update { it.copy(busy = false, feedback = feedback) } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt new file mode 100644 index 0000000..9f2d369 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt @@ -0,0 +1,147 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.admin + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.SupportPageDto +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.components.EmptyView +import com.runicgateway.app.ui.components.ErrorView +import com.runicgateway.app.ui.components.LoadingView + +/** + * The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close, + * over `/admin/shard/pages…` (admin/moderator). Empty when there are no open pages + * (or the shard is offline); every read/write degrades cleanly (§7). + */ +@Composable +fun AdminSupportScreen( + modifier: Modifier = Modifier, + viewModel: AdminSupportViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + var replyTo by remember { mutableStateOf(null) } + + Column(modifier.fillMaxSize()) { + state.feedback?.let { + Text( + text = stringResource(it.messageRes), + style = MaterialTheme.typography.bodySmall, + color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + when (val s = state.pages) { + is UiState.Loading -> LoadingView() + is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load) + is UiState.Success -> + if (s.data.isEmpty()) { + EmptyView(stringResource(R.string.admin_support_empty)) + } else { + LazyColumn(Modifier.fillMaxSize().padding(16.dp)) { + items(s.data, key = { it.pageId }) { page -> + SupportPageCard( + page = page, + busy = state.busy, + onReply = { replyTo = page }, + onClose = { viewModel.close(page.pageId) }, + ) + } + } + } + } + } + + replyTo?.let { page -> + RespondDialog( + page = page, + onDismiss = { replyTo = null }, + onSend = { message, close -> + viewModel.respond(page.pageId, message, close) + replyTo = null + }, + ) + } +} + +@Composable +private fun SupportPageCard( + page: SupportPageDto, + busy: Boolean, + onReply: () -> Unit, + onClose: () -> Unit, +) { + Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Column(Modifier.padding(12.dp)) { + val who = page.sender?.name ?: page.sender?.account ?: page.pageId + Text( + text = listOfNotNull(page.type, who).joinToString(" · "), + style = MaterialTheme.typography.bodyLarge, + ) + page.message?.takeIf { it.isNotBlank() }?.let { + Spacer(Modifier.height(4.dp)) + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onReply, enabled = !busy) { Text(stringResource(R.string.admin_support_reply)) } + TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.admin_support_close)) } + } + } + } +} + +@Composable +private fun RespondDialog( + page: SupportPageDto, + onDismiss: () -> Unit, + onSend: (message: String, close: Boolean) -> Unit, +) { + var message by rememberSaveable { mutableStateOf("") } + var alsoClose by rememberSaveable { mutableStateOf(true) } + + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { TextButton(onClick = { onSend(message, alsoClose) }) { Text(stringResource(R.string.admin_support_send)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, + title = { Text(stringResource(R.string.admin_support_reply)) }, + text = { + Column { + OutlinedTextField(value = message, onValueChange = { message = it }, label = { Text(stringResource(R.string.admin_support_message)) }, modifier = Modifier.fillMaxWidth()) + Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = alsoClose, onCheckedChange = { alsoClose = it }) + Text(stringResource(R.string.admin_support_close_after)) + } + } + }, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt new file mode 100644 index 0000000..64648ef --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt @@ -0,0 +1,87 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.admin + +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.data.api.dto.SupportPageDto +import com.runicgateway.app.data.repository.AdminRepository +import com.runicgateway.app.ui.UiState +import com.runicgateway.app.ui.toUiState +import dagger.hilt.android.lifecycle.HiltViewModel +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 javax.inject.Inject + +/** + * Drives the support (help-page) queue (PLAN.md §1, M10): list open pages, reply + * (optionally closing), and close, over `/admin/shard/pages…` (admin/moderator). + * The list is served from shard state — empty when no tickets (or the shard is + * offline); writes need a live sidecar and fail cleanly otherwise (§7). + */ +@HiltViewModel +class AdminSupportViewModel @Inject constructor( + private val admin: AdminRepository, +) : ViewModel() { + + data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int) + + data class State( + val pages: UiState> = UiState.Loading, + val busy: Boolean = false, + val feedback: Feedback? = null, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + init { + load() + } + + fun clearFeedback() = _state.update { it.copy(feedback = null) } + + fun load() { + _state.update { it.copy(pages = UiState.Loading) } + viewModelScope.launch { _state.update { it.copy(pages = admin.supportPages().toUiState()) } } + } + + fun respond(id: String, message: String, close: Boolean) { + if (message.isBlank()) { + _state.update { it.copy(feedback = Feedback(false, R.string.admin_support_message_required)) } + return + } + mutate(R.string.admin_support_responded) { admin.respondPage(id, message.trim(), close) } + } + + fun close(id: String) = mutate(R.string.admin_support_closed) { admin.closePage(id) } + + private fun mutate(@StringRes okRes: Int, block: suspend () -> ApiResult) { + if (_state.value.busy) return + _state.update { it.copy(busy = true, feedback = null) } + viewModelScope.launch { + val feedback = when (val r = block()) { + is ApiResult.Ok -> Feedback(true, okRes) + is ApiResult.HttpError -> Feedback( + false, + when (r.status) { + 403 -> R.string.admin_forbidden + 404 -> R.string.admin_support_unknown_page + 503 -> R.string.admin_mod_shard_offline + else -> R.string.admin_action_failed + }, + ) + is ApiResult.NetworkError -> Feedback(false, R.string.error_network) + } + if (feedback.ok) load() + _state.update { it.copy(busy = false, feedback = feedback) } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index 613b0bf..283a6dd 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -57,6 +57,8 @@ val APP_MENU: List = listOf( // Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call. MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF), MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF), + MenuEntry(Routes.ADMIN_MODERATION, R.string.menu_admin_moderation, MenuAccess.MODERATOR), + MenuEntry(Routes.ADMIN_SUPPORT, R.string.menu_admin_support, MenuAccess.MODERATOR), ) /** diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a8e1b2..02c3acd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -100,6 +100,38 @@ A title is required. Slug and title are required. + + Account action + Account + Serial (0x…) + Reason (ban) + Ban duration (seconds; blank = indefinite) + Kick + Ban + Unban + Broadcast + Message to everyone online + Broadcast + Account kicked. + Account banned. + Ban cleared. + Message broadcast. + Enter an account or serial. + Enter a message to broadcast. + The shard is offline — the action couldn\'t be delivered. + + + No open help pages. + Reply + Close + Send + Reply message + Close the page after replying + Reply sent. + Page closed. + Enter a reply message. + That page is no longer in the queue. + Sign in Sign in with your shard account.