feat: add feedback dialog

This commit is contained in:
Infi 2023-03-27 03:22:35 +02:00
parent 9f6d184d18
commit 576efa01ad
9 changed files with 382 additions and 30 deletions

View File

@ -64,10 +64,14 @@ android {
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
buildConfigField "String", "SENTRY_DSN", "\"${property('revoltbuild.properties', 'sentry.dsn', 'RVX_SENTRY_DSN')}\""
buildConfigField "Boolean", "ANALYSIS_ENABLED", "${property('revoltbuild.properties', 'analysis.enabled', 'RVX_ANALYSIS_ENABLED')}"
buildConfigField "String", "ANALYSIS_BASEURL", "\"${property('revoltbuild.properties', 'analysis.base_url', 'RVX_ANALYSIS_BASEURL')}\""
}
debug {
buildConfigField "String", "SENTRY_DSN", "\"${property('revoltbuild.properties', 'sentry.dsn', 'RVX_SENTRY_DSN')}\""
buildConfigField "Boolean", "ANALYSIS_ENABLED", "${property('revoltbuild.properties', 'analysis.enabled', 'RVX_ANALYSIS_ENABLED')}"
buildConfigField "String", "ANALYSIS_BASEURL", "\"${property('revoltbuild.properties', 'analysis.base_url', 'RVX_ANALYSIS_BASEURL')}\""
}
}
compileOptions {
@ -112,9 +116,9 @@ dependencies {
implementation 'androidx.compose.material:material'
implementation 'androidx.compose.material3:material3'
implementation "androidx.compose.ui:ui-tooling-preview"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0'
implementation 'androidx.activity:activity-compose:1.6.1'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1'
implementation 'androidx.activity:activity-compose:1.7.0'
// Accompanist - Jetpack Compose Extensions
implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version"

View File

@ -14,12 +14,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.navigation.compose.dialog
import chat.revolt.api.settings.GlobalState
import chat.revolt.screens.SplashScreen
import chat.revolt.screens.about.AboutScreen
import chat.revolt.screens.about.AttributionScreen
import chat.revolt.screens.about.PlaceholderScreen
import chat.revolt.screens.chat.ChatRouterScreen
import chat.revolt.screens.chat.dialogs.FeedbackDialog
import chat.revolt.screens.login.GreeterScreen
import chat.revolt.screens.login.LoginScreen
import chat.revolt.screens.login.MfaScreen
@ -111,6 +113,7 @@ fun AppEntrypoint() {
composable("settings") { SettingsScreen(navController) }
composable("settings/appearance") { AppearanceSettingsScreen(navController) }
dialog("settings/feedback") { FeedbackDialog(navController) }
composable("about") { AboutScreen(navController) }
composable("about/oss") { AttributionScreen(navController) }

View File

@ -4,18 +4,27 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.compose.runtime.mutableStateMapOf
import chat.revolt.BuildConfig
import chat.revolt.api.realtime.DisconnectionState
import chat.revolt.api.realtime.RealtimeSocket
import chat.revolt.api.routes.user.fetchSelf
import chat.revolt.api.schemas.*
import chat.revolt.api.schemas.Channel
import chat.revolt.api.schemas.Emoji
import chat.revolt.api.schemas.Message
import chat.revolt.api.schemas.Server
import chat.revolt.api.schemas.User
import chat.revolt.api.unreads.Unreads
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.plugins.websocket.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.header
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@ -61,6 +70,10 @@ val RevoltHttp = HttpClient(OkHttp) {
defaultRequest {
url(REVOLT_BASE)
header(
"User-Agent",
"Ktor RevoltAndroid/${BuildConfig.VERSION_NAME} (Android ${android.os.Build.VERSION.SDK_INT}; ${android.os.Build.MANUFACTURER} ${android.os.Build.DEVICE})"
)
}
}

View File

@ -10,6 +10,9 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
/**
* Convenience wrapper around [TextField] that sets the [KeyboardOptions] and [VisualTransformation]
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FormTextField(
@ -18,14 +21,19 @@ fun FormTextField(
onChange: (it: String) -> Unit,
modifier: Modifier = Modifier,
type: KeyboardType = KeyboardType.Text,
supportingText: @Composable (() -> Unit)? = null,
singleLine: Boolean = true,
enabled: Boolean = true,
) {
TextField(
value = value,
onValueChange = onChange,
singleLine = true,
singleLine = singleLine,
keyboardOptions = KeyboardOptions(keyboardType = type),
visualTransformation = if (type == KeyboardType.Password) PasswordVisualTransformation() else VisualTransformation.None,
label = { Text(label) },
supportingText = supportingText,
enabled = enabled,
modifier = modifier
)
}

View File

@ -0,0 +1,268 @@
package chat.revolt.screens.chat.dialogs
import android.util.Log
import android.widget.Toast
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.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.revolt.BuildConfig
import chat.revolt.R
import chat.revolt.api.REVOLT_BASE
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltHttp
import chat.revolt.components.generic.FormTextField
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
enum class FeedbackType(val value: String) {
Satisfaction("satisfaction"),
FeatureRequest("featurerequest"),
BugReport("bug"),
UxFeedback("ux"),
Performance("performance"),
Security("security"),
Other("other")
}
@Serializable
data class FeedbackBody(
val type: String,
val message: String,
val api_host: String,
val app_id: String,
val app_version: String,
val app_build: String,
val android_api: String,
val android_device: String,
val android_manufacturer: String,
@SerialName("id_for_spam_protection_pls_dont_spam_but_if_you_do_i_will_know")
val author: String
)
suspend fun sendFeedback(type: FeedbackType, message: String): String {
val response = RevoltHttp.post("${BuildConfig.ANALYSIS_BASEURL}/api/feedback/android") {
setBody(
FeedbackBody(
type = type.value,
message = message,
api_host = REVOLT_BASE,
app_id = BuildConfig.APPLICATION_ID,
app_version = BuildConfig.VERSION_NAME,
app_build = BuildConfig.VERSION_CODE.toString(),
android_api = android.os.Build.VERSION.SDK_INT.toString(),
android_device = android.os.Build.DEVICE,
android_manufacturer = android.os.Build.MANUFACTURER,
author = RevoltAPI.selfId ?: "RevoltAPI.selfId is null"
)
)
contentType(ContentType.Application.Json)
}
Log.d("FeedbackDialog", "Feedback sent: ${response.bodyAsText()}")
return response.bodyAsText()
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
fun FeedbackDialog(navController: NavController) {
if (!BuildConfig.ANALYSIS_ENABLED) {
AlertDialog(onDismissRequest = {
navController.popBackStack()
}, title = {
Text(
text = stringResource(id = R.string.settings_feedback_disabled_title),
modifier = Modifier.fillMaxWidth()
)
}, text = {
Text(text = stringResource(id = R.string.settings_feedback_disabled_message))
}, confirmButton = {
TextButton(onClick = {
navController.popBackStack()
}) {
Text(text = stringResource(id = R.string.ok))
}
})
return
}
val category = mapOf(
FeedbackType.Satisfaction to stringResource(R.string.settings_feedback_category_satisfaction),
FeedbackType.FeatureRequest to stringResource(R.string.settings_feedback_category_feature),
FeedbackType.BugReport to stringResource(R.string.settings_feedback_category_bug),
FeedbackType.UxFeedback to stringResource(R.string.settings_feedback_category_ux),
FeedbackType.Performance to stringResource(R.string.settings_feedback_category_performance),
FeedbackType.Security to stringResource(R.string.settings_feedback_category_security),
FeedbackType.Other to stringResource(R.string.settings_feedback_category_other)
)
val categoryDropdownExpanded = remember { mutableStateOf(false) }
val categoryDropdownSelected = remember { mutableStateOf(FeedbackType.Satisfaction) }
val message = remember { mutableStateOf("") }
val error = remember { mutableStateOf("") }
val sending = remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val context = LocalContext.current
AlertDialog(
onDismissRequest = {
navController.popBackStack()
},
title = {
Text(
text = stringResource(id = R.string.settings_feedback),
modifier = Modifier.fillMaxWidth()
)
},
text = {
Column {
Text(
text = stringResource(id = R.string.settings_feedback_introduction)
)
Spacer(modifier = Modifier.height(16.dp))
Box {
TextField(
value = category[categoryDropdownSelected.value]
?: stringResource(id = R.string.unknown),
onValueChange = {},
label = {
Text(
text = stringResource(id = R.string.settings_feedback_category),
)
},
readOnly = true,
trailingIcon = {
IconToggleButton(
checked = categoryDropdownExpanded.value,
onCheckedChange = {
categoryDropdownExpanded.value = it
}
) {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = stringResource(id = R.string.settings_feedback_category)
)
}
},
modifier = Modifier.fillMaxWidth()
)
DropdownMenu(
expanded = categoryDropdownExpanded.value,
onDismissRequest = {
categoryDropdownExpanded.value = false
},
) {
category.forEach { (key, value) ->
DropdownMenuItem(
text = {
Text(text = value)
},
onClick = {
categoryDropdownSelected.value = key
categoryDropdownExpanded.value = false
}
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
FormTextField(
value = message.value,
label = stringResource(id = R.string.settings_feedback_message),
onChange = {
message.value = it
},
supportingText = {
Text(
text = "${message.value.length}/1250",
)
},
enabled = !sending.value,
singleLine = false,
modifier = Modifier.fillMaxWidth()
)
}
},
dismissButton = {
TextButton(
onClick = {
navController.popBackStack()
},
modifier = Modifier.testTag("feedback_cancel"),
enabled = !sending.value,
) {
Text(text = stringResource(id = R.string.cancel))
}
},
confirmButton = {
TextButton(
onClick = {
if (message.value.length > 1250) {
error.value =
context.getString(R.string.settings_feedback_message_too_long, 1250)
} else {
error.value = ""
sending.value = true
scope.launch {
try {
val result =
sendFeedback(categoryDropdownSelected.value, message.value)
Log.d("FeedbackDialog", "Feedback sent with result: $result")
if (result.isBlank()) {
navController.popBackStack()
} else {
error.value = result
Toast.makeText(context, error.value, Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Log.e("FeedbackDialog", "Error sending feedback", e)
error.value = context.getString(R.string.settings_feedback_error)
} finally {
sending.value = false
}
}
}
},
enabled = !sending.value && message.value.isNotBlank(),
modifier = Modifier.testTag("feedback_submit")
) {
Text(text = stringResource(id = R.string.report_submit))
}
}
)
}

View File

@ -30,7 +30,7 @@ import chat.revolt.components.chat.Message
import chat.revolt.components.generic.FormTextField
import kotlinx.coroutines.launch
enum class ReportingState {
enum class ReportFlowState {
Reason,
Sending,
Done,
@ -52,13 +52,13 @@ fun ReportMessageDialog(
val author = RevoltAPI.userCache[message.author]
val messageIsBridged = author?.let { author.bot != null && message.masquerade != null } ?: false
val state = remember { mutableStateOf(ReportingState.Reason) }
val state = remember { mutableStateOf(ReportFlowState.Reason) }
val selectedReason = remember { mutableStateOf("Illegal") }
val userAddedContext = remember { mutableStateOf("") }
when (state.value) {
ReportingState.Reason -> {
ReportFlowState.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),
@ -169,19 +169,18 @@ fun ReportMessageDialog(
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
})
},
supportingText = {
Text(
text = stringResource(id = R.string.report_reason_additional_hint)
)
}
)
}
},
dismissButton = {
@ -197,7 +196,7 @@ fun ReportMessageDialog(
confirmButton = {
TextButton(
onClick = {
state.value = ReportingState.Sending
state.value = ReportFlowState.Sending
},
modifier = Modifier.testTag("report_send")
) {
@ -207,7 +206,7 @@ fun ReportMessageDialog(
)
}
ReportingState.Sending -> {
ReportFlowState.Sending -> {
AlertDialog(
onDismissRequest = {},
title = {
@ -231,9 +230,9 @@ fun ReportMessageDialog(
ContentReportReason.valueOf(selectedReason.value),
userAddedContext.value
)
state.value = ReportingState.Done
state.value = ReportFlowState.Done
} catch (e: Exception) {
state.value = ReportingState.Error
state.value = ReportFlowState.Error
Log.e("ReportMessageDialog", "Failed to report message", e)
return@launch
}
@ -246,7 +245,7 @@ fun ReportMessageDialog(
)
}
ReportingState.Done -> {
ReportFlowState.Done -> {
val scope = rememberCoroutineScope()
AlertDialog(
@ -315,7 +314,7 @@ fun ReportMessageDialog(
)
}
ReportingState.Error -> {
ReportFlowState.Error -> {
AlertDialog(
onDismissRequest = {
navController.popBackStack()

View File

@ -1,10 +1,14 @@
package chat.revolt.screens.settings
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -73,6 +77,40 @@ fun SettingsScreen(
) {
navController.navigate("about")
}
Divider()
SheetClickable(
icon = { modifier ->
Icon(
imageVector = Icons.Default.Build,
contentDescription = stringResource(id = R.string.settings_feedback),
modifier = modifier
)
},
label = { textStyle ->
Text(text = stringResource(id = R.string.settings_feedback), style = textStyle)
},
modifier = Modifier.testTag("settings_view_feedback")
) {
navController.navigate("settings/feedback")
}
SheetClickable(
icon = { modifier ->
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(id = R.string.logout),
modifier = modifier
)
},
label = { textStyle ->
Text(text = stringResource(id = R.string.logout), style = textStyle)
},
modifier = Modifier.testTag("settings_view_logout")
) {
Toast.makeText(navController.context, "Not implemented yet", Toast.LENGTH_SHORT).show()
}
}
}
}

View File

@ -211,4 +211,21 @@
<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>
<string name="settings_feedback">Feedback</string>
<string name="settings_feedback_introduction">Any feedback you have for Revolt is greatly appreciated and all feedback is read by the development team of our Android app.</string>
<string name="settings_feedback_category">Category</string>
<string name="settings_feedback_category_satisfaction">General-purpose feedback</string>
<string name="settings_feedback_category_feature">Feature request</string>
<string name="settings_feedback_category_bug">Bug report</string>
<string name="settings_feedback_category_ux">User experience (UI/UX)</string>
<string name="settings_feedback_category_performance">Performance</string>
<string name="settings_feedback_category_security">Security</string>
<string name="settings_feedback_category_other">Other</string>
<string name="settings_feedback_message">Message</string>
<string name="settings_feedback_message_too_long">Message is too long. Please shorten it to %1$d characters or less.</string>
<string name="settings_feedback_error">An error occurred while sending your feedback. Please try again later.</string>
<string name="settings_feedback_disabled_title">Feedback unavailable</string>
<string name="settings_feedback_disabled_message">Feedback is not available on this build of Revolt. Support for self-built versions is limited.</string>
</resources>

View File

@ -1 +1,3 @@
sentry.dsn=
sentry.dsn=
analysis.enabled=false
analysis.base_url=