feat: message reporting and block route

This commit is contained in:
Infi 2023-02-23 01:22:11 +01:00
parent 5cf0943e26
commit c38fc51e23
9 changed files with 664 additions and 13 deletions

1
.idea/.name Normal file
View File

@ -0,0 +1 @@
Revolt

View File

@ -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
}
}

View File

@ -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")
}

View File

@ -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,
)

View File

@ -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<ContentReportReason> {
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<UserReportReason> {
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,
)

View File

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

View File

@ -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 = {}
)
}
}
}

View File

@ -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}")
}
}
}

View File

@ -3,6 +3,7 @@
<string name="back">← Back</string>
<string name="next">Next →</string>
<string name="ok">OK</string>
<string name="cancel">Cancel</string>
<string name="lets_go">Let\'s go</string>
<string name="loading">Fetching some info, hang in there…</string>
@ -138,6 +139,50 @@
<string name="message_context_sheet_actions_react">React</string>
<string name="message_context_sheet_actions_report">Report</string>
<string name="report">Report</string>
<string name="report_cancel">Cancel</string>
<string name="report_message">Thank you for taking the time to report this message. Please provide a reason for reporting this message.</string>
<string name="report_message_preview">Selected message:</string>
<string name="report_server">Thank you for taking the time to report this server. Please provide a reason for reporting this server.</string>
<string name="report_server_preview">Selected server:</string>
<string name="report_user">Thank you for taking the time to report this user. Please provide a reason for reporting this user.</string>
<string name="report_user_preview">Selected user:</string>
<string name="report_reason">Reason</string>
<string name="report_reason_content_illegal">Illegal content</string>
<string name="report_reason_content_promotes_harm">Promotes harm</string>
<string name="report_reason_content_spam_abuse">Spam or similar platform abuse</string>
<string name="report_reason_content_malware">Malware or phishing</string>
<string name="report_reason_content_harassment">Harassment or cyberbullying</string>
<string name="report_reason_content_other">Other</string>
<string name="report_reason_user_spam_abuse">Spam or similar platform abuse</string>
<string name="report_reason_user_inappropriate_content">Inappropriate content (like NSFW)</string>
<string name="report_reason_user_impersonation">Impersonation</string>
<string name="report_reason_user_ban_evasion">Ban evasion</string>
<string name="report_reason_user_underage">Not of minimum age to use the platform</string>
<string name="report_reason_user_other">Other</string>
<string name="report_reason_additional">Additional information</string>
<string name="report_reason_additional_hint">Any additional information that may help us in our investigation. Optional.</string>
<string name="report_submit">Submit</string>
<string name="report_submitting">Reporting…</string>
<string name="report_submit_success">Reported</string>
<string name="report_submit_thanks">Thank you for helping to keep Revolt safe. We will review your report as soon as possible.</string>
<string name="report_submit_error_header">Error</string>
<string name="report_submit_error">An error occurred while submitting your report. Please try again later.</string>
<string name="report_submit_close">Close</string>
<string name="report_block_question">Would you like to block this user?</string>
<string name="report_block_yes">Block</string>
<string name="report_block_no">Don\'t block</string>
<string name="settings_appearance">Appearance</string>
<string name="settings_appearance_theme">Theme</string>
<string name="settings_appearance_theme_none">System</string>
@ -147,4 +192,4 @@
<string name="settings_appearance_theme_m3dynamic">Material You</string>
<string name="settings_appearance_theme_m3dynamic_unsupported">Material You (unsupported)</string>
<string name="settings_appearance_theme_m3dynamic_unsupported_toast">Material You is not supported on this device.</string>
</resources>
</resources>