From c38fc51e2350d22ac0cd3a63d1018af753935cd8 Mon Sep 17 00:00:00 2001 From: Infi Date: Thu, 23 Feb 2023 01:22:11 +0100 Subject: [PATCH] feat: message reporting and block route --- .idea/.name | 1 + .../revolt/api/routes/safety/Reporting.kt | 106 ++++++ .../revolt/api/routes/user/Relationships.kt | 43 +++ .../java/chat/revolt/api/schemas/Messages.kt | 1 + .../java/chat/revolt/api/schemas/Safety.kt | 118 +++++++ .../revolt/screens/chat/ChatRouterScreen.kt | 16 +- .../dialogs/safety/ReportMessageDialog.kt | 332 ++++++++++++++++++ .../chat/sheets/MessageContextSheet.kt | 13 +- app/src/main/res/values/strings.xml | 47 ++- 9 files changed, 664 insertions(+), 13 deletions(-) create mode 100644 .idea/.name create mode 100644 app/src/main/java/chat/revolt/api/routes/safety/Reporting.kt create mode 100644 app/src/main/java/chat/revolt/api/routes/user/Relationships.kt create mode 100644 app/src/main/java/chat/revolt/api/schemas/Safety.kt create mode 100644 app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 00000000..8a14fc66 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Revolt \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/routes/safety/Reporting.kt b/app/src/main/java/chat/revolt/api/routes/safety/Reporting.kt new file mode 100644 index 00000000..1c7ecde1 --- /dev/null +++ b/app/src/main/java/chat/revolt/api/routes/safety/Reporting.kt @@ -0,0 +1,106 @@ +package chat.revolt.api.routes.safety + +import chat.revolt.api.RevoltAPI +import chat.revolt.api.RevoltError +import chat.revolt.api.RevoltHttp +import chat.revolt.api.RevoltJson +import chat.revolt.api.schemas.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import kotlinx.serialization.SerializationException + +suspend fun putMessageReport( + messageId: String, + reason: ContentReportReason, + additionalContext: String? = null +) { + val fullMessageReport = FullMessageReport( + content = MessageReport( + report_reason = reason, + id = messageId, + ), + additional_context = additionalContext, + ) + + val response = RevoltHttp.post("/safety/report") { + headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) + setBody( + RevoltJson.encodeToString( + FullMessageReport.serializer(), + fullMessageReport + ) + ) + } + .bodyAsText() + + try { + val error = RevoltJson.decodeFromString(RevoltError.serializer(), response) + throw Error(error.type) + } catch (e: SerializationException) { + // Not an error + } +} + +suspend fun putServerReport( + serverId: String, + reason: ContentReportReason, + additionalContext: String? = null +) { + val fullServerReport = FullServerReport( + content = ServerReport( + report_reason = reason, + id = serverId, + ), + additional_context = additionalContext, + ) + + val response = RevoltHttp.post("/safety/report") { + headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) + setBody( + RevoltJson.encodeToString( + FullServerReport.serializer(), + fullServerReport + ) + ) + } + .bodyAsText() + + try { + val error = RevoltJson.decodeFromString(RevoltError.serializer(), response) + throw Error(error.type) + } catch (e: SerializationException) { + // Not an error + } +} + +suspend fun putUserReport( + userId: String, + reason: UserReportReason, + additionalContext: String? = null +) { + val fullUserReport = FullUserReport( + content = UserReport( + report_reason = reason, + id = userId, + ), + additional_context = additionalContext, + ) + + val response = RevoltHttp.post("/safety/report") { + headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) + setBody( + RevoltJson.encodeToString( + FullUserReport.serializer(), + fullUserReport + ) + ) + } + .bodyAsText() + + try { + val error = RevoltJson.decodeFromString(RevoltError.serializer(), response) + throw Error(error.type) + } catch (e: SerializationException) { + // Not an error + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/routes/user/Relationships.kt b/app/src/main/java/chat/revolt/api/routes/user/Relationships.kt new file mode 100644 index 00000000..4276b062 --- /dev/null +++ b/app/src/main/java/chat/revolt/api/routes/user/Relationships.kt @@ -0,0 +1,43 @@ +package chat.revolt.api.routes.user + +import chat.revolt.api.RevoltAPI +import chat.revolt.api.RevoltError +import chat.revolt.api.RevoltHttp +import chat.revolt.api.RevoltJson +import io.ktor.client.request.* +import io.ktor.client.statement.* +import kotlinx.serialization.SerializationException + +suspend fun blockUser(userId: String) { + val response = RevoltHttp.put("/users/$userId/block") { + headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) + } + .bodyAsText() + + try { + val error = RevoltJson.decodeFromString(RevoltError.serializer(), response) + throw Error(error.type) + } catch (e: SerializationException) { + // Not an error + } + + val user = RevoltAPI.userCache[userId] ?: return + RevoltAPI.userCache[userId] = user.copy(relationship = "Blocked") +} + +suspend fun unblockUser(userId: String) { + val response = RevoltHttp.delete("/users/$userId/block") { + headers.append(RevoltAPI.TOKEN_HEADER_NAME, RevoltAPI.sessionToken) + } + .bodyAsText() + + try { + val error = RevoltJson.decodeFromString(RevoltError.serializer(), response) + throw Error(error.type) + } catch (e: SerializationException) { + // Not an error + } + + val user = RevoltAPI.userCache[userId] ?: return + RevoltAPI.userCache[userId] = user.copy(relationship = "None") +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/schemas/Messages.kt b/app/src/main/java/chat/revolt/api/schemas/Messages.kt index bed72cdd..21a08f70 100644 --- a/app/src/main/java/chat/revolt/api/schemas/Messages.kt +++ b/app/src/main/java/chat/revolt/api/schemas/Messages.kt @@ -40,6 +40,7 @@ data class Message( edited = partial.edited ?: edited, embeds = partial.embeds ?: embeds, mentions = partial.mentions ?: mentions, + masquerade = partial.masquerade ?: masquerade, type = partial.type ?: type, tail = partial.tail ?: tail, ) diff --git a/app/src/main/java/chat/revolt/api/schemas/Safety.kt b/app/src/main/java/chat/revolt/api/schemas/Safety.kt new file mode 100644 index 00000000..9b7c981a --- /dev/null +++ b/app/src/main/java/chat/revolt/api/schemas/Safety.kt @@ -0,0 +1,118 @@ +package chat.revolt.api.schemas + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable +enum class ContentReportReason(val value: String) { + NoneSpecified("NoneSpecified"), + Illegal("Illegal"), + PromotesHarm("PromotesHarm"), + SpamAbuse("SpamAbuse"), + Malware("Malware"), + Harassment("Harassment"); + + companion object : KSerializer { + override val descriptor: SerialDescriptor + get() { + return PrimitiveSerialDescriptor( + "chat.revolt.api.schemas.ContentReportReason", + PrimitiveKind.STRING + ) + } + + override fun deserialize(decoder: Decoder): ContentReportReason = + when (val value = decoder.decodeString()) { + "NoneSpecified" -> NoneSpecified + "Illegal" -> Illegal + "PromotesHarm" -> PromotesHarm + "SpamAbuse" -> SpamAbuse + "Malware" -> Malware + "Harassment" -> Harassment + else -> throw IllegalArgumentException("Unknown ContentReportReason: $value") + } + + override fun serialize(encoder: Encoder, value: ContentReportReason) { + return encoder.encodeString(value.value) + } + } +} + +@Serializable +enum class UserReportReason(val value: String) { + NoneSpecified("NoneSpecified"), + SpamAbuse("SpamAbuse"), + InappropriateProfile("InappropriateProfile"), + Impersonation("Impersonation"), + BanEvasion("BanEvasion"), + Underage("Underage"); + + companion object : KSerializer { + override val descriptor: SerialDescriptor + get() { + return PrimitiveSerialDescriptor( + "chat.revolt.api.schemas.UserReportReason", + PrimitiveKind.STRING + ) + } + + override fun deserialize(decoder: Decoder): UserReportReason = + when (val value = decoder.decodeString()) { + "NoneSpecified" -> NoneSpecified + "SpamAbuse" -> SpamAbuse + "InappropriateProfile" -> InappropriateProfile + "Impersonation" -> Impersonation + "BanEvasion" -> BanEvasion + "Underage" -> Underage + else -> throw IllegalArgumentException("Unknown UserReportReason: $value") + } + + override fun serialize(encoder: Encoder, value: UserReportReason) { + return encoder.encodeString(value.value) + } + } +} + +@Serializable +data class MessageReport( + val type: String = "Message", + val id: String, + val report_reason: ContentReportReason, +) + +@Serializable +data class FullMessageReport( + val content: MessageReport, + val additional_context: String? = null, +) + +@Serializable +data class ServerReport( + val type: String = "Server", + val id: String, + val report_reason: ContentReportReason, +) + +@Serializable +data class FullServerReport( + val content: ServerReport, + val additional_context: String? = null, +) + +@Serializable +data class UserReport( + val type: String = "User", + val id: String, + val report_reason: UserReportReason, +) + +@Serializable +data class FullUserReport( + val content: UserReport, + val additional_context: String? = null, +) \ No newline at end of file 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 91949e0c..dfd086d3 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -25,10 +25,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController +import androidx.navigation.compose.* import chat.revolt.R import chat.revolt.api.RevoltAPI import chat.revolt.api.realtime.DisconnectionState @@ -43,6 +40,7 @@ import chat.revolt.components.screens.chat.drawer.server.DrawerServer import chat.revolt.components.screens.chat.drawer.server.DrawerServerlikeIcon import chat.revolt.components.screens.chat.drawer.server.ServerDrawerSeparator import chat.revolt.components.screens.chat.rememberDoubleDrawerState +import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog import chat.revolt.screens.chat.sheets.ChannelInfoSheet import chat.revolt.screens.chat.sheets.MessageContextSheet import chat.revolt.screens.chat.sheets.StatusSheet @@ -302,6 +300,16 @@ fun ChatRouterScreen(topNav: NavController, viewModel: ChatRouterViewModel = vie bottomSheet("status") { StatusSheet(navController = navController, topNav = topNav) } + + dialog("report/message/{messageId}") { backStackEntry -> + val messageId = backStackEntry.arguments?.getString("messageId") + if (messageId != null) { + ReportMessageDialog( + navController = navController, + messageId = messageId + ) + } + } } } } 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 new file mode 100644 index 00000000..0a774cc5 --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/chat/dialogs/safety/ReportMessageDialog.kt @@ -0,0 +1,332 @@ +package chat.revolt.screens.chat.dialogs.safety + +import android.util.Log +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.user.blockUser +import chat.revolt.api.schemas.ContentReportReason +import chat.revolt.components.chat.Message +import chat.revolt.components.generic.FormTextField +import kotlinx.coroutines.launch + +enum class ReportingState { + Reason, + Sending, + Done, + Error +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportMessageDialog( + navController: NavController, + messageId: String, +) { + val message = RevoltAPI.messageCache[messageId] + if (message == null) { + navController.popBackStack() + return + } + + val state = remember { mutableStateOf(ReportingState.Reason) } + + val selectedReason = remember { mutableStateOf("Illegal") } + val userAddedContext = remember { mutableStateOf("") } + + when (state.value) { + ReportingState.Reason -> { + val reasons = mapOf( + "Illegal" to stringResource(id = R.string.report_reason_content_illegal), + "PromotesHarm" to stringResource(id = R.string.report_reason_content_promotes_harm), + "SpamAbuse" to stringResource(id = R.string.report_reason_content_spam_abuse), + "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), + ) + val reasonDropdownExpanded = remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { + // nothing - prevent mistaps from closing the dialog + }, + title = { + Text( + text = stringResource(id = R.string.report), + modifier = Modifier.fillMaxWidth() + ) + }, + text = { + Column { + Text(text = stringResource(id = R.string.report_message)) + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(id = R.string.report_message_preview), + fontSize = 12.sp, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + + Box( + modifier = Modifier + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)) + .verticalScroll(rememberScrollState()) + .heightIn(max = 200.dp) + .padding(bottom = 8.dp) + ) { + Message( + message = message.copy( + tail = false, + masquerade = null + ), + truncate = false + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Box { + TextField( + value = reasons[selectedReason.value] + ?: stringResource(id = R.string.unknown), + onValueChange = { + selectedReason.value = it + }, + 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) + ) + } + }, + modifier = Modifier.fillMaxWidth() + ) + + DropdownMenu( + 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)) + + Text( + text = stringResource(id = R.string.report_reason_additional_hint), + fontSize = 12.sp, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + FormTextField( + value = userAddedContext.value, + label = stringResource(id = R.string.report_reason_additional), + onChange = { + userAddedContext.value = it + }) + } + }, + dismissButton = { + TextButton(onClick = { + navController.popBackStack() + }) { + Text(text = stringResource(id = R.string.report_cancel)) + } + }, + confirmButton = { + TextButton(onClick = { + state.value = ReportingState.Sending + }) { + Text(text = stringResource(id = R.string.report_submit)) + } + } + ) + } + + ReportingState.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 message $messageId") + putMessageReport( + messageId, + ContentReportReason.valueOf(selectedReason.value), + userAddedContext.value + ) + state.value = ReportingState.Done + } catch (e: Exception) { + state.value = ReportingState.Error + Log.e("ReportMessageDialog", "Failed to report message", e) + return@launch + } + } + } + } + }, + dismissButton = {}, + confirmButton = {} + ) + } + + ReportingState.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() + }) { + Text(text = stringResource(id = R.string.report_block_no)) + } + }, + confirmButton = { + TextButton(onClick = { + scope.launch { + blockUser(message.author ?: return@launch) + } + navController.popBackStack() + }) { + Text(text = stringResource(id = R.string.report_block_yes)) + } + } + ) + } + + ReportingState.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() + }) { + Text(text = stringResource(id = R.string.ok)) + } + }, + confirmButton = {} + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/sheets/MessageContextSheet.kt b/app/src/main/java/chat/revolt/screens/chat/sheets/MessageContextSheet.kt index 92f6a866..b2f673f0 100644 --- a/app/src/main/java/chat/revolt/screens/chat/sheets/MessageContextSheet.kt +++ b/app/src/main/java/chat/revolt/screens/chat/sheets/MessageContextSheet.kt @@ -25,7 +25,6 @@ import chat.revolt.api.RevoltAPI import chat.revolt.callbacks.UiCallbacks import chat.revolt.components.chat.Message import chat.revolt.components.generic.SheetClickable -import chat.revolt.api.schemas.Message as MessageSchema @Composable fun MessageContextSheet( @@ -54,7 +53,10 @@ fun MessageContextSheet( .padding(bottom = 8.dp) ) { Message( - message = message.mergeWithPartial(MessageSchema(tail = false)), + message = message.copy( + tail = false, + masquerade = null + ), truncate = true ) } @@ -264,12 +266,7 @@ fun MessageContextSheet( ) }, ) { - Toast.makeText( - context, - context.getString(R.string.comingsoon_toast), - Toast.LENGTH_SHORT - ).show() - navController.popBackStack() + navController.navigate("report/message/${message.id}") } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9672c9e1..5252e573 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ ← Back Next → + OK Cancel Let\'s go Fetching some info, hang in there… @@ -138,6 +139,50 @@ React Report + Report + Cancel + + Thank you for taking the time to report this message. Please provide a reason for reporting this message. + Selected message: + Thank you for taking the time to report this server. Please provide a reason for reporting this server. + Selected server: + Thank you for taking the time to report this user. Please provide a reason for reporting this user. + Selected user: + + Reason + + Illegal content + Promotes harm + Spam or similar platform abuse + Malware or phishing + Harassment or cyberbullying + Other + + Spam or similar platform abuse + Inappropriate content (like NSFW) + Impersonation + Ban evasion + Not of minimum age to use the platform + Other + + Additional information + Any additional information that may help us in our investigation. Optional. + + Submit + Reporting… + Reported + + Thank you for helping to keep Revolt safe. We will review your report as soon as possible. + + Error + An error occurred while submitting your report. Please try again later. + + Close + + Would you like to block this user? + Block + Don\'t block + Appearance Theme System @@ -147,4 +192,4 @@ Material You Material You (unsupported) Material You is not supported on this device. - \ No newline at end of file +