diff --git a/app/src/main/java/chat/revolt/api/schemas/Safety.kt b/app/src/main/java/chat/revolt/api/schemas/Safety.kt index 304073b1..fc407390 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Safety.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Safety.kt @@ -12,8 +12,16 @@ import kotlinx.serialization.encoding.Encoder enum class ContentReportReason(val value: String) { NoneSpecified("NoneSpecified"), Illegal("Illegal"), + IllegalGoods("IllegalGoods"), + IllegalExtortion("IllegalExtortion"), + IllegalPornography("IllegalPornography"), + IllegalHacking("IllegalHacking"), + ExtremeViolence("ExtremeViolence"), PromotesHarm("PromotesHarm"), + UnsolicitedSpam("UnsolicitedSpam"), + Raid("Raid"), SpamAbuse("SpamAbuse"), + ScamsFraud("ScamsFraud"), Malware("Malware"), Harassment("Harassment"); @@ -30,8 +38,16 @@ enum class ContentReportReason(val value: String) { when (val value = decoder.decodeString()) { "NoneSpecified" -> NoneSpecified "Illegal" -> Illegal + "IllegalGoods" -> IllegalGoods + "IllegalExtortion" -> IllegalExtortion + "IllegalPornography" -> IllegalPornography + "IllegalHacking" -> IllegalHacking + "ExtremeViolence" -> ExtremeViolence "PromotesHarm" -> PromotesHarm + "UnsolicitedSpam" -> UnsolicitedSpam + "Raid" -> Raid "SpamAbuse" -> SpamAbuse + "ScamsFraud" -> ScamsFraud "Malware" -> Malware "Harassment" -> Harassment else -> throw IllegalArgumentException("Unknown ContentReportReason: $value") @@ -46,6 +62,7 @@ enum class ContentReportReason(val value: String) { @Serializable enum class UserReportReason(val value: String) { NoneSpecified("NoneSpecified"), + UnsolicitedSpam("UnsolicitedSpam"), SpamAbuse("SpamAbuse"), InappropriateProfile("InappropriateProfile"), Impersonation("Impersonation"), @@ -64,6 +81,7 @@ enum class UserReportReason(val value: String) { override fun deserialize(decoder: Decoder): UserReportReason = when (val value = decoder.decodeString()) { "NoneSpecified" -> NoneSpecified + "UnsolicitedSpam" -> UnsolicitedSpam "SpamAbuse" -> SpamAbuse "InappropriateProfile" -> InappropriateProfile "Impersonation" -> Impersonation diff --git a/app/src/main/java/chat/revolt/components/screens/settings/UserOverview.kt b/app/src/main/java/chat/revolt/components/screens/settings/UserOverview.kt index d7605051..d3af080f 100644 --- a/app/src/main/java/chat/revolt/components/screens/settings/UserOverview.kt +++ b/app/src/main/java/chat/revolt/components/screens/settings/UserOverview.kt @@ -54,7 +54,7 @@ fun SelfUserOverview() { } @Composable -fun UserOverview(user: User) { +fun UserOverview(user: User, internalPadding: Boolean = true) { var profile by remember { mutableStateOf(null) } LaunchedEffect(user) { @@ -67,7 +67,7 @@ fun UserOverview(user: User) { } } - RawUserOverview(user, profile) + RawUserOverview(user, profile, internalPadding = internalPadding) } @Composable @@ -75,7 +75,8 @@ fun RawUserOverview( user: User, profile: Profile? = null, pfpUrl: String? = null, - backgroundUrl: String? = null + backgroundUrl: String? = null, + internalPadding: Boolean = true ) { val context = LocalContext.current var teamMemberFlair by remember { mutableStateOf(null) } @@ -94,7 +95,7 @@ fun RawUserOverview( Box( contentAlignment = Alignment.BottomStart, modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = if (internalPadding) 16.dp else 0.dp) .clip(MaterialTheme.shapes.large) .then( if (user.id in SpecialUsers.TEAM_MEMBER_FLAIRS.keys) { diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index bc222781..d2c40988 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.DismissibleDrawerSheet import androidx.compose.material3.DismissibleNavigationDrawer import androidx.compose.material3.DrawerState @@ -95,6 +94,7 @@ import chat.revolt.internals.Changelogs import chat.revolt.ndk.Pipebomb import chat.revolt.persistence.KVStorage import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog +import chat.revolt.screens.chat.dialogs.safety.ReportUserDialog import chat.revolt.screens.chat.views.FriendsScreen import chat.revolt.screens.chat.views.HomeScreen import chat.revolt.screens.chat.views.NoCurrentChannelScreen @@ -1050,16 +1050,10 @@ fun ChannelNavigator( dialog("report/user/{userId}") { backStackEntry -> val userId = backStackEntry.arguments?.getString("userId") if (userId != null) { - AlertDialog(onDismissRequest = { - navController.popBackStack() - }) { - Text("Report user $userId") - Button(onClick = { - navController.popBackStack() - }) { - Text("Close") - } - } + ReportUserDialog( + navController = navController, + userId = userId + ) } } } diff --git a/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt index 8cc57800..ccc79e31 100644 --- a/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt +++ b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt @@ -19,6 +19,9 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme @@ -50,13 +53,14 @@ import chat.revolt.components.chat.Message import chat.revolt.components.generic.FormTextField import kotlinx.coroutines.launch -enum class ReportFlowState { +enum class MessageReportFlowState { Reason, Sending, Done, Error } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReportMessageDialog(navController: NavController, messageId: String) { val message = RevoltAPI.messageCache[messageId] @@ -68,20 +72,28 @@ fun ReportMessageDialog(navController: NavController, messageId: String) { val author = RevoltAPI.userCache[message.author] val messageIsBridged = author?.let { author.bot != null && message.masquerade != null } ?: false - val state = remember { mutableStateOf(ReportFlowState.Reason) } + val state = remember { mutableStateOf(MessageReportFlowState.Reason) } val selectedReason = remember { mutableStateOf("Illegal") } val userAddedContext = remember { mutableStateOf("") } when (state.value) { - ReportFlowState.Reason -> { + MessageReportFlowState.Reason -> { val reasons = mapOf( "Illegal" to stringResource(id = R.string.report_reason_content_illegal), + "IllegalGoods" to stringResource(id = R.string.report_reason_content_illegal_goods), + "IllegalExtortion" to stringResource(id = R.string.report_reason_content_illegal_extortion), + "IllegalPornography" to stringResource(id = R.string.report_reason_content_illegal_pornography), + "IllegalHacking" to stringResource(id = R.string.report_reason_content_illegal_hacking), + "ExtremeViolence" to stringResource(id = R.string.report_reason_content_extreme_violence), "PromotesHarm" to stringResource(id = R.string.report_reason_content_promotes_harm), + "UnsolicitedSpam" to stringResource(id = R.string.report_reason_content_unsolicited_spam), + "Raid" to stringResource(id = R.string.report_reason_content_raid), "SpamAbuse" to stringResource(id = R.string.report_reason_content_spam_abuse), + "ScamsFraud" to stringResource(id = R.string.report_reason_content_scams_fraud), "Malware" to stringResource(id = R.string.report_reason_content_malware), "Harassment" to stringResource(id = R.string.report_reason_content_harassment), - "Other" to stringResource(id = R.string.report_reason_content_other) + "NoneSpecified" to stringResource(id = R.string.report_reason_content_other) ) val reasonDropdownExpanded = remember { mutableStateOf(false) } @@ -91,7 +103,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) { }, title = { Text( - text = stringResource(id = R.string.report), + text = stringResource(id = R.string.report_message_heading), modifier = Modifier.fillMaxWidth() ) }, @@ -134,38 +146,30 @@ fun ReportMessageDialog(navController: NavController, messageId: String) { Spacer(modifier = Modifier.height(16.dp)) - Box { + ExposedDropdownMenuBox( + expanded = reasonDropdownExpanded.value, + onExpandedChange = { + reasonDropdownExpanded.value = it + }, + ) { TextField( value = reasons[selectedReason.value] ?: stringResource(id = R.string.unknown), - onValueChange = { - selectedReason.value = it - }, + readOnly = true, + onValueChange = {}, label = { Text( text = stringResource(id = R.string.report_reason) ) }, - readOnly = true, trailingIcon = { - IconToggleButton( - checked = reasonDropdownExpanded.value, - onCheckedChange = { - reasonDropdownExpanded.value = it - } - ) { - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = stringResource( - id = R.string.report_reason - ) - ) - } + ExposedDropdownMenuDefaults.TrailingIcon(expanded = reasonDropdownExpanded.value) }, - modifier = Modifier.fillMaxWidth() + colors = ExposedDropdownMenuDefaults.textFieldColors(), + modifier = Modifier.menuAnchor() ) - DropdownMenu( + ExposedDropdownMenu( expanded = reasonDropdownExpanded.value, onDismissRequest = { reasonDropdownExpanded.value = false @@ -216,7 +220,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) { confirmButton = { TextButton( onClick = { - state.value = ReportFlowState.Sending + state.value = MessageReportFlowState.Sending }, modifier = Modifier.testTag("report_send") ) { @@ -226,7 +230,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) { ) } - ReportFlowState.Sending -> { + MessageReportFlowState.Sending -> { AlertDialog( onDismissRequest = {}, title = { @@ -250,9 +254,9 @@ fun ReportMessageDialog(navController: NavController, messageId: String) { ContentReportReason.valueOf(selectedReason.value), userAddedContext.value ) - state.value = ReportFlowState.Done + state.value = MessageReportFlowState.Done } catch (e: Error) { - state.value = ReportFlowState.Error + state.value = MessageReportFlowState.Error Log.e("ReportMessageDialog", "Failed to report message", e) return@launch } @@ -265,7 +269,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) { ) } - ReportFlowState.Done -> { + MessageReportFlowState.Done -> { val scope = rememberCoroutineScope() AlertDialog( @@ -334,7 +338,7 @@ fun ReportMessageDialog(navController: NavController, messageId: String) { ) } - ReportFlowState.Error -> { + MessageReportFlowState.Error -> { AlertDialog( onDismissRequest = { navController.popBackStack() diff --git a/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportUserDialog.kt b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportUserDialog.kt new file mode 100644 index 00000000..8e0b918c --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportUserDialog.kt @@ -0,0 +1,353 @@ +package chat.revolt.screens.chat.dialogs.safety + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import chat.revolt.R +import chat.revolt.api.RevoltAPI +import chat.revolt.api.routes.safety.putMessageReport +import chat.revolt.api.routes.safety.putUserReport +import chat.revolt.api.routes.user.blockUser +import chat.revolt.api.schemas.ContentReportReason +import chat.revolt.api.schemas.UserReportReason +import chat.revolt.components.chat.Message +import chat.revolt.components.generic.FormTextField +import chat.revolt.components.screens.settings.UserOverview +import kotlinx.coroutines.launch + +enum class UserReportFlowState { + Reason, + Sending, + Done, + Error +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportUserDialog(navController: NavController, userId: String) { + val user = RevoltAPI.userCache[userId] + if (user == null) { + navController.popBackStack() + return + } + + val state = remember { mutableStateOf(UserReportFlowState.Reason) } + + val selectedReason = remember { mutableStateOf("UnsolicitedSpam") } + val userAddedContext = remember { mutableStateOf("") } + + when (state.value) { + UserReportFlowState.Reason -> { + val reasons = mapOf( + "UnsolicitedSpam" to stringResource(id = R.string.report_reason_user_unsolicited_spam), + "SpamAbuse" to stringResource(id = R.string.report_reason_user_spam_abuse), + "InappropriateProfile" to stringResource(id = R.string.report_reason_user_inappropriate_content), + "Impersonation" to stringResource(id = R.string.report_reason_user_impersonation), + "BanEvasion" to stringResource(id = R.string.report_reason_user_ban_evasion), + "Underage" to stringResource(id = R.string.report_reason_user_underage), + "NoneSpecified" to stringResource(id = R.string.report_reason_user_other) + ) + val reasonDropdownExpanded = remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { + // nothing - prevent mistaps from closing the dialog + }, + title = { + Text( + text = stringResource(id = R.string.report_user_heading), + modifier = Modifier.fillMaxWidth() + ) + }, + text = { + Column { + Text(text = stringResource(id = R.string.report_user)) + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(id = R.string.report_user_preview), + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(4.dp)) + + UserOverview(user, internalPadding = false) + + Spacer(modifier = Modifier.height(16.dp)) + + ExposedDropdownMenuBox( + expanded = reasonDropdownExpanded.value, + onExpandedChange = { + reasonDropdownExpanded.value = it + }, + ) { + TextField( + value = reasons[selectedReason.value] + ?: stringResource(id = R.string.unknown), + readOnly = true, + onValueChange = {}, + label = { + Text( + text = stringResource(id = R.string.report_reason) + ) + }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = reasonDropdownExpanded.value) + }, + colors = ExposedDropdownMenuDefaults.textFieldColors(), + modifier = Modifier.menuAnchor() + ) + + ExposedDropdownMenu( + expanded = reasonDropdownExpanded.value, + onDismissRequest = { + reasonDropdownExpanded.value = false + } + ) { + reasons.forEach { (key, value) -> + DropdownMenuItem( + text = { + Text(text = value) + }, + onClick = { + selectedReason.value = key + reasonDropdownExpanded.value = false + } + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + FormTextField( + value = userAddedContext.value, + label = stringResource(id = R.string.report_reason_additional), + onChange = { + userAddedContext.value = it + }, + supportingText = { + Text( + text = stringResource( + id = R.string.report_reason_additional_hint + ) + ) + } + ) + } + }, + dismissButton = { + TextButton( + onClick = { + navController.popBackStack() + }, + modifier = Modifier.testTag("report_cancel") + ) { + Text(text = stringResource(id = R.string.report_cancel)) + } + }, + confirmButton = { + TextButton( + onClick = { + state.value = UserReportFlowState.Sending + }, + modifier = Modifier.testTag("report_send") + ) { + Text(text = stringResource(id = R.string.report_submit)) + } + } + ) + } + + UserReportFlowState.Sending -> { + AlertDialog( + onDismissRequest = {}, + title = { + Text( + text = stringResource(id = R.string.report_submitting), + modifier = Modifier.fillMaxWidth() + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + LaunchedEffect(true) { + launch { + try { + Log.d("ReportMessageDialog", "Reporting user $userId") + putUserReport( + userId, + UserReportReason.valueOf(selectedReason.value), + userAddedContext.value + ) + state.value = UserReportFlowState.Done + } catch (e: Error) { + state.value = UserReportFlowState.Error + Log.e("ReportMessageDialog", "Failed to report user", e) + return@launch + } + } + } + } + }, + dismissButton = {}, + confirmButton = {} + ) + } + + UserReportFlowState.Done -> { + val scope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = { + navController.popBackStack() + }, + icon = { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, // decorative + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + text = stringResource(id = R.string.report_submit_success), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + text = { + Column { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.report_submit_thanks), + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = stringResource(id = R.string.report_block_question), + textAlign = TextAlign.Center, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + }, + dismissButton = { + TextButton( + onClick = { + navController.popBackStack() + }, + modifier = Modifier.testTag("report_block_no") + ) { + Text(text = stringResource(id = R.string.report_block_no)) + } + }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + blockUser(userId) + } + navController.popBackStack() + }, + modifier = Modifier.testTag("report_block_yes") + ) { + Text(text = stringResource(id = R.string.report_block_yes)) + } + } + ) + } + + UserReportFlowState.Error -> { + AlertDialog( + onDismissRequest = { + navController.popBackStack() + }, + icon = { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, // decorative + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { + Text( + text = stringResource(id = R.string.report_submit_error_header), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + text = { + Column { + Text( + text = stringResource(id = R.string.report_submit_error), + textAlign = TextAlign.Center + ) + } + }, + dismissButton = { + TextButton( + onClick = { + navController.popBackStack() + }, + modifier = Modifier.testTag("report_error_ok") + ) { + Text(text = stringResource(id = R.string.ok)) + } + }, + confirmButton = {} + ) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 086a6c83..f32f0381 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -303,23 +303,35 @@ Report Cancel - Thank you for taking the time to report this message. Please provide a reason for reporting this message. + Tell us what\'s wrong with this message + Thank you for taking the time to report this message. Your report will not be shared with the user who sent the message or the server\'s moderators. Selected message: Note: This message may have been sent from another platform. It is recommended to also report the message on the platform it was sent from. - Thank you for taking the time to report this server. Please provide a reason for reporting this server. + Tell us what\'s wrong with this server + Thank you for taking the time to report this server. Your report will not be shared with the server\'s moderators. Selected server: - Thank you for taking the time to report this user. Please provide a reason for reporting this user. + Tell us what\'s wrong with this user + Thank you for taking the time to report this user. Your report will not be shared with the user or the server\'s moderators. Selected user: - Reason + Pick a category Illegal content + Drugs or other illegal goods + Extortion or blackmail + Revenge or underage pornography + Illegal hacking or cracking + Extreme violence, gore or animal cruelty Promotes harm + Unsolicited advertising or spam + Raid or spam attack Spam or similar platform abuse + Scams or fraud Malware or phishing Harassment or cyberbullying Other + Unsolicited advertising or spam Spam or similar platform abuse Inappropriate content (like NSFW) Impersonation