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.persistence.KVStorage
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.views.FriendsScreen
import chat.revolt.screens.chat.views.HomeScreen
@ -590,6 +591,9 @@ fun ChatRouterScreen(
onHideSheet = {
serverContextSheetState.hide()
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 ->
val userId = backStackEntry.arguments?.getString("userId")
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
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -30,32 +23,27 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
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.RevoltAPI
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.UIMarkdown
import chat.revolt.components.screens.settings.ServerOverview
import chat.revolt.internals.Platform
import kotlinx.coroutines.launch
@Composable
fun ServerContextSheet(serverId: String, onHideSheet: suspend () -> Unit) {
fun ServerContextSheet(
serverId: String,
onReportServer: () -> Unit,
onHideSheet: suspend () -> Unit
) {
val server = RevoltAPI.serverCache[serverId]
if (server == null) {
@ -152,98 +140,7 @@ fun ServerContextSheet(serverId: String, onHideSheet: suspend () -> Unit) {
Column(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 0.dp, bottom = 16.dp)
) {
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
)
}
}
}
ServerOverview(server)
Column(
modifier = Modifier.padding(horizontal = 4.dp)
@ -321,6 +218,21 @@ fun ServerContextSheet(serverId: String, onHideSheet: suspend () -> Unit) {
}
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 = {
Icon(
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_no">Stay</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_description">This user may have been deleted or you may not have permission to view them.</string>