feat: server reporting

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2024-01-03 20:13:54 +01:00
parent 46b75c3ed3
commit f9b778e3c4
5 changed files with 487 additions and 110 deletions

View File

@ -0,0 +1,131 @@
package chat.revolt.components.screens.settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.revolt.R
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.schemas.Server
import chat.revolt.api.schemas.ServerFlags
import chat.revolt.api.schemas.has
import chat.revolt.components.generic.IconPlaceholder
import chat.revolt.components.generic.RemoteImage
@Composable
fun ServerOverview(server: Server) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.large),
contentAlignment = Alignment.BottomStart
) {
server.banner?.let {
Box(
modifier = Modifier
.background(Color.Black.copy(alpha = 0.25f))
.height(166.dp)
.fillMaxWidth()
)
RemoteImage(
url = "$REVOLT_FILES/banners/${it.id}",
description = null,
modifier = Modifier
.height(166.dp)
.fillMaxWidth(),
contentScale = ContentScale.FillWidth
)
Box(
modifier = Modifier
.background(
Brush.verticalGradient(
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
)
)
)
.height(166.dp)
.fillMaxWidth()
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp)
) {
server.icon?.let {
RemoteImage(
url = "$REVOLT_FILES/icons/${it.id}/server.png?max_side=256",
description = null,
modifier = Modifier
.clip(CircleShape)
.height(48.dp)
.width(48.dp),
contentScale = ContentScale.Crop
)
} ?: run {
IconPlaceholder(
name = server.name ?: stringResource(R.string.unknown),
modifier = Modifier
.clip(CircleShape)
.height(48.dp)
.width(48.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
CompositionLocalProvider(LocalContentColor provides Color.White) {
if (server.flags has ServerFlags.Official) {
Icon(
painter = painterResource(id = R.drawable.ic_revolt_decagram_24dp),
contentDescription = stringResource(R.string.server_flag_official),
modifier = Modifier
.padding(end = 8.dp)
.size(24.dp)
)
}
if (server.flags has ServerFlags.Verified) {
Icon(
painter = painterResource(id = R.drawable.ic_check_decagram_24dp),
contentDescription = stringResource(R.string.server_flag_verified),
modifier = Modifier
.padding(end = 8.dp)
.size(24.dp)
)
}
Text(
text = server.name ?: stringResource(R.string.unknown),
style = MaterialTheme.typography.labelLarge,
fontSize = 16.sp
)
}
}
}
}

View File

@ -94,6 +94,7 @@ import chat.revolt.internals.Changelogs
import chat.revolt.ndk.Pipebomb import chat.revolt.ndk.Pipebomb
import chat.revolt.persistence.KVStorage import chat.revolt.persistence.KVStorage
import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog import chat.revolt.screens.chat.dialogs.safety.ReportMessageDialog
import chat.revolt.screens.chat.dialogs.safety.ReportServerDialog
import chat.revolt.screens.chat.dialogs.safety.ReportUserDialog import chat.revolt.screens.chat.dialogs.safety.ReportUserDialog
import chat.revolt.screens.chat.views.FriendsScreen import chat.revolt.screens.chat.views.FriendsScreen
import chat.revolt.screens.chat.views.HomeScreen import chat.revolt.screens.chat.views.HomeScreen
@ -590,6 +591,9 @@ fun ChatRouterScreen(
onHideSheet = { onHideSheet = {
serverContextSheetState.hide() serverContextSheetState.hide()
showServerContextSheet = false showServerContextSheet = false
},
onReportServer = {
navController.navigate("report/server/${serverContextSheetTarget}")
} }
) )
} }
@ -1088,6 +1092,16 @@ fun ChannelNavigator(
} }
} }
dialog("report/server/{serverId}") { backStackEntry ->
val serverId = backStackEntry.arguments?.getString("serverId")
if (serverId != null) {
ReportServerDialog(
navController = navController,
serverId = serverId
)
}
}
dialog("report/user/{userId}") { backStackEntry -> dialog("report/user/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId") val userId = backStackEntry.arguments?.getString("userId")
if (userId != null) { if (userId != null) {

View File

@ -0,0 +1,319 @@
package chat.revolt.screens.chat.dialogs.safety
import android.util.Log
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.material.icons.Icons
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.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.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.putServerReport
import chat.revolt.api.schemas.ContentReportReason
import chat.revolt.components.generic.FormTextField
import chat.revolt.components.screens.settings.ServerOverview
import kotlinx.coroutines.launch
enum class ServerReportFlowState {
Reason,
Sending,
Done,
Error
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReportServerDialog(navController: NavController, serverId: String) {
val server = RevoltAPI.serverCache[serverId]
if (server == null) {
navController.popBackStack()
return
}
val state = remember { mutableStateOf(ServerReportFlowState.Reason) }
val selectedReason = remember { mutableStateOf("Illegal") }
val userAddedContext = remember { mutableStateOf("") }
when (state.value) {
ServerReportFlowState.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),
"NoneSpecified" 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_server_heading),
modifier = Modifier.fillMaxWidth()
)
},
text = {
Column {
Text(text = stringResource(id = R.string.report_server))
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.report_server_preview),
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
ServerOverview(server)
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 = ServerReportFlowState.Sending
},
modifier = Modifier.testTag("report_send")
) {
Text(text = stringResource(id = R.string.report_submit))
}
}
)
}
ServerReportFlowState.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("ReportServerDialog", "Reporting server $serverId")
putServerReport(
serverId,
ContentReportReason.valueOf(selectedReason.value),
userAddedContext.value
)
state.value = ServerReportFlowState.Done
} catch (e: Error) {
state.value = ServerReportFlowState.Error
Log.e("ReportServerDialog", "Failed to report server", e)
return@launch
}
}
}
}
},
dismissButton = {},
confirmButton = {}
)
}
ServerReportFlowState.Done -> {
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
)
}
}
},
confirmButton = {
TextButton(
onClick = {
navController.popBackStack()
},
modifier = Modifier.testTag("report_close")
) {
Text(text = stringResource(id = R.string.report_submit_close))
}
}
)
}
ServerReportFlowState.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 = {}
)
}
}
}

View File

@ -1,28 +1,21 @@
package chat.revolt.sheets package chat.revolt.sheets
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -30,32 +23,27 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.revolt.R import chat.revolt.R
import chat.revolt.api.REVOLT_FILES
import chat.revolt.api.RevoltAPI import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.server.leaveOrDeleteServer import chat.revolt.api.routes.server.leaveOrDeleteServer
import chat.revolt.api.schemas.ServerFlags
import chat.revolt.api.schemas.has
import chat.revolt.components.generic.IconPlaceholder
import chat.revolt.components.generic.RemoteImage
import chat.revolt.components.generic.SheetClickable import chat.revolt.components.generic.SheetClickable
import chat.revolt.components.generic.UIMarkdown import chat.revolt.components.generic.UIMarkdown
import chat.revolt.components.screens.settings.ServerOverview
import chat.revolt.internals.Platform import chat.revolt.internals.Platform
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun ServerContextSheet(serverId: String, onHideSheet: suspend () -> Unit) { fun ServerContextSheet(
serverId: String,
onReportServer: () -> Unit,
onHideSheet: suspend () -> Unit
) {
val server = RevoltAPI.serverCache[serverId] val server = RevoltAPI.serverCache[serverId]
if (server == null) { if (server == null) {
@ -152,98 +140,7 @@ fun ServerContextSheet(serverId: String, onHideSheet: suspend () -> Unit) {
Column( Column(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 16.dp) modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 16.dp)
) { ) {
Box( ServerOverview(server)
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.large),
contentAlignment = Alignment.BottomStart
) {
server.banner?.let {
Box(
modifier = Modifier
.background(Color.Black.copy(alpha = 0.25f))
.height(166.dp)
.fillMaxWidth()
)
RemoteImage(
url = "$REVOLT_FILES/banners/${it.id}",
description = null,
modifier = Modifier
.height(166.dp)
.fillMaxWidth(),
contentScale = ContentScale.FillWidth
)
Box(
modifier = Modifier
.background(
Brush.verticalGradient(
listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
)
)
)
.height(166.dp)
.fillMaxWidth()
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(16.dp)
) {
server.icon?.let {
RemoteImage(
url = "$REVOLT_FILES/icons/${it.id}/server.png?max_side=256",
description = null,
modifier = Modifier
.clip(CircleShape)
.height(48.dp)
.width(48.dp),
contentScale = ContentScale.Crop
)
} ?: run {
IconPlaceholder(
name = server.name ?: stringResource(R.string.unknown),
modifier = Modifier
.clip(CircleShape)
.height(48.dp)
.width(48.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
CompositionLocalProvider(LocalContentColor provides Color.White) {
if (server.flags has ServerFlags.Official) {
Icon(
painter = painterResource(id = R.drawable.ic_revolt_decagram_24dp),
contentDescription = stringResource(R.string.server_flag_official),
modifier = Modifier
.padding(end = 8.dp)
.size(24.dp)
)
}
if (server.flags has ServerFlags.Verified) {
Icon(
painter = painterResource(id = R.drawable.ic_check_decagram_24dp),
contentDescription = stringResource(R.string.server_flag_verified),
modifier = Modifier
.padding(end = 8.dp)
.size(24.dp)
)
}
Text(
text = server.name ?: stringResource(R.string.unknown),
style = MaterialTheme.typography.labelLarge,
fontSize = 16.sp
)
}
}
}
Column( Column(
modifier = Modifier.padding(horizontal = 4.dp) modifier = Modifier.padding(horizontal = 4.dp)
@ -321,6 +218,21 @@ fun ServerContextSheet(serverId: String, onHideSheet: suspend () -> Unit) {
} }
if (server.owner != RevoltAPI.selfId) { if (server.owner != RevoltAPI.selfId) {
SheetClickable(icon = {
Icon(
painter = painterResource(id = R.drawable.ic_flag_24dp),
contentDescription = null,
modifier = it
)
}, label = {
Text(
text = stringResource(id = R.string.server_context_sheet_actions_report),
style = it
)
}, dangerous = true) {
onReportServer()
}
SheetClickable(icon = { SheetClickable(icon = {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_arrow_left_bold_box_24dp), painter = painterResource(id = R.drawable.ic_arrow_left_bold_box_24dp),

View File

@ -295,6 +295,7 @@
<string name="server_context_sheet_actions_leave_confirm_yes">Leave</string> <string name="server_context_sheet_actions_leave_confirm_yes">Leave</string>
<string name="server_context_sheet_actions_leave_confirm_no">Stay</string> <string name="server_context_sheet_actions_leave_confirm_no">Stay</string>
<string name="server_context_sheet_actions_leave_silently">Leave Silently</string> <string name="server_context_sheet_actions_leave_silently">Leave Silently</string>
<string name="server_context_sheet_actions_report">Report</string>
<string name="user_info_sheet_user_not_found">Can\'t resolve this user</string> <string name="user_info_sheet_user_not_found">Can\'t resolve this user</string>
<string name="user_info_sheet_user_not_found_description">This user may have been deleted or you may not have permission to view them.</string> <string name="user_info_sheet_user_not_found_description">This user may have been deleted or you may not have permission to view them.</string>