feat: user action buttons

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2023-11-11 16:25:24 +01:00
parent adba9da754
commit 9159f33c3b
10 changed files with 398 additions and 27 deletions

View File

@ -28,6 +28,7 @@ import chat.revolt.api.realtime.frames.receivable.UserUpdateFrame
import chat.revolt.api.realtime.frames.sendable.AuthorizationFrame import chat.revolt.api.realtime.frames.sendable.AuthorizationFrame
import chat.revolt.api.realtime.frames.sendable.PingFrame import chat.revolt.api.realtime.frames.sendable.PingFrame
import chat.revolt.api.routes.server.fetchMember import chat.revolt.api.routes.server.fetchMember
import chat.revolt.api.schemas.Channel
import chat.revolt.api.settings.GlobalState import chat.revolt.api.settings.GlobalState
import chat.revolt.api.settings.SyncedSettings import chat.revolt.api.settings.SyncedSettings
import io.ktor.client.plugins.websocket.ws import io.ktor.client.plugins.websocket.ws
@ -304,6 +305,18 @@ object RealtimeSocket {
existing.mergeWithPartial(channelUpdateFrame.data) existing.mergeWithPartial(channelUpdateFrame.data)
} }
"ChannelCreate" -> {
val channelCreateFrame =
RevoltJson.decodeFromString(Channel.serializer(), rawFrame)
Log.d(
"RealtimeSocket",
"Received channel create frame for ${channelCreateFrame.id}, with name ${channelCreateFrame.name}. Adding to cache."
)
RevoltAPI.channelCache[channelCreateFrame.id!!] = channelCreateFrame
}
"ChannelAck" -> { "ChannelAck" -> {
val channelAckFrame = val channelAckFrame =
RevoltJson.decodeFromString(ChannelAckFrame.serializer(), rawFrame) RevoltJson.decodeFromString(ChannelAckFrame.serializer(), rawFrame)

View File

@ -0,0 +1,23 @@
package chat.revolt.api.routes.user
import chat.revolt.api.RevoltError
import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson
import chat.revolt.api.schemas.Channel
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.SerializationException
suspend fun openDM(userId: String): Channel {
val response = RevoltHttp.get("/users/$userId/dm")
.bodyAsText()
try {
val error = RevoltJson.decodeFromString(RevoltError.serializer(), response)
throw Error(error.type)
} catch (e: SerializationException) {
// Not an error
}
return RevoltJson.decodeFromString(Channel.serializer(), response)
}

View File

@ -1,14 +1,16 @@
package chat.revolt.api.routes.user package chat.revolt.api.routes.user
import chat.revolt.api.RevoltAPI
import chat.revolt.api.RevoltError import chat.revolt.api.RevoltError
import chat.revolt.api.RevoltHttp import chat.revolt.api.RevoltHttp
import chat.revolt.api.RevoltJson import chat.revolt.api.RevoltJson
import io.ktor.client.request.delete import io.ktor.client.request.delete
import io.ktor.client.request.post
import io.ktor.client.request.put import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.SerializationException import kotlinx.serialization.SerializationException
import kotlin.collections.set
suspend fun blockUser(userId: String) { suspend fun blockUser(userId: String) {
val response = RevoltHttp.put("/users/$userId/block") val response = RevoltHttp.put("/users/$userId/block")
@ -20,9 +22,6 @@ suspend fun blockUser(userId: String) {
} catch (e: SerializationException) { } catch (e: SerializationException) {
// Not an error // Not an error
} }
val user = RevoltAPI.userCache[userId] ?: return
RevoltAPI.userCache[userId] = user.copy(relationship = "Blocked")
} }
suspend fun unblockUser(userId: String) { suspend fun unblockUser(userId: String) {
@ -35,9 +34,33 @@ suspend fun unblockUser(userId: String) {
} catch (e: SerializationException) { } catch (e: SerializationException) {
// Not an error // Not an error
} }
}
val user = RevoltAPI.userCache[userId] ?: return suspend fun friendUser(username: String) {
RevoltAPI.userCache[userId] = user.copy(relationship = "None") val response = RevoltHttp.post("/users/friend") {
contentType(ContentType.Application.Json)
setBody(mapOf("username" to username))
}
val body = response.bodyAsText()
try {
val error = RevoltJson.decodeFromString(RevoltError.serializer(), body)
throw Error(error.type)
} catch (e: SerializationException) {
// Not an error
}
}
suspend fun acceptFriendRequest(userId: String) {
val response = RevoltHttp.put("/users/$userId/friend")
.bodyAsText()
try {
val error = RevoltJson.decodeFromString(RevoltError.serializer(), response)
throw Error(error.type)
} catch (e: SerializationException) {
// Not an error
}
} }
suspend fun unfriendUser(userId: String) { suspend fun unfriendUser(userId: String) {
@ -50,7 +73,4 @@ suspend fun unfriendUser(userId: String) {
} catch (e: SerializationException) { } catch (e: SerializationException) {
// Not an error // Not an error
} }
val user = RevoltAPI.userCache[userId] ?: return
RevoltAPI.userCache[userId] = user.copy(relationship = "None")
} }

View File

@ -7,6 +7,8 @@ sealed class Action {
data class SwitchChannel(val channelId: String) : Action() data class SwitchChannel(val channelId: String) : Action()
data class LinkInfo(val url: String) : Action() data class LinkInfo(val url: String) : Action()
data class EmoteInfo(val emoteId: String) : Action() data class EmoteInfo(val emoteId: String) : Action()
data class TopNavigate(val route: String) : Action()
data class ChatNavigate(val route: String) : Action()
} }
val ActionChannel = Channel<Action>( val ActionChannel = Channel<Action>(

View File

@ -0,0 +1,256 @@
package chat.revolt.components.screens.settings
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import chat.revolt.R
import chat.revolt.api.RevoltAPI
import chat.revolt.api.routes.user.acceptFriendRequest
import chat.revolt.api.routes.user.blockUser
import chat.revolt.api.routes.user.friendUser
import chat.revolt.api.routes.user.openDM
import chat.revolt.api.routes.user.unblockUser
import chat.revolt.api.routes.user.unfriendUser
import chat.revolt.api.schemas.User
import chat.revolt.callbacks.Action
import chat.revolt.callbacks.ActionChannel
import chat.revolt.internals.Platform
import kotlinx.coroutines.launch
@Composable
fun UserButtons(
user: User,
dismissSheet: suspend () -> Unit
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
var menuOpen by remember { mutableStateOf(false) }
if (user.id == null) return Row {
Button(
onClick = {
scope.launch {
friendUser("${user.username}#${user.discriminator}")
}
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.user_info_sheet_add_friend))
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
when (user.relationship) {
"None" -> {
Button(
onClick = {
scope.launch {
friendUser("${user.username}#${user.discriminator}")
}
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.user_info_sheet_add_friend))
}
}
"User" -> {
Button(
onClick = {
scope.launch {
ActionChannel.send(Action.TopNavigate("settings/profile"))
// We must now close the bottom sheet,
// else we will crash if we try to open this sheet again
dismissSheet()
}
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.user_info_sheet_edit_profile))
}
}
"Friend" -> {
TextButton(
onClick = {
scope.launch {
val dm = openDM(user.id)
if (dm.id != null) {
if (RevoltAPI.channelCache[dm.id] == null)
RevoltAPI.channelCache[dm.id] = dm
ActionChannel.send(Action.SwitchChannel(dm.id))
dismissSheet()
} else {
Toast.makeText(
context,
context.getString(R.string.user_info_sheet_failed_to_open_dm),
Toast.LENGTH_SHORT
).show()
}
}
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.user_info_sheet_send_message))
}
// Remove friend (in overflow menu)
}
"Outgoing" -> {
Button(
onClick = {
scope.launch {
unfriendUser(user.id)
}
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.user_info_sheet_cancel_request))
}
}
"Incoming" -> {
Button(
onClick = {
scope.launch {
acceptFriendRequest(user.id)
}
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.user_info_sheet_accept_request))
}
Button(
onClick = {
scope.launch {
unfriendUser(user.id)
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.user_info_sheet_decline_request))
}
}
"Blocked" -> {
Button(
onClick = {
scope.launch {
unblockUser(user.id)
}
},
modifier = Modifier.weight(1f)
) {
Text(stringResource(R.string.user_info_sheet_unblock))
}
}
}
when (user.relationship) {
"Friend", "Incoming", "Outgoing", "None" -> {
Column { // Prevent the dropdown menu from counting towards arrangement spacing
IconButton(
onClick = {
menuOpen = true
}
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = stringResource(R.string.menu)
)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
when (user.relationship) {
"Friend" -> {
DropdownMenuItem(
text = {
Text(stringResource(R.string.user_info_sheet_remove_friend))
},
onClick = {
scope.launch {
unfriendUser(user.id)
}
}
)
}
}
DropdownMenuItem(
text = {
Text(stringResource(R.string.user_info_sheet_block))
},
onClick = {
scope.launch {
blockUser(user.id)
}
}
)
DropdownMenuItem(
text = {
Text(stringResource(R.string.user_info_sheet_copy_id))
},
onClick = {
scope.launch {
clipboard.setText(AnnotatedString(user.id))
}
}
)
DropdownMenuItem(
text = {
Text(stringResource(R.string.user_info_sheet_report))
},
onClick = {
scope.launch {
ActionChannel.send(Action.ChatNavigate("report/user/${user.id}"))
if (Platform.needsShowClipboardNotification()) {
Toast.makeText(
context,
context.getString(R.string.copied),
Toast.LENGTH_SHORT
).show()
}
}
}
)
}
}
}
}
}
}

View File

@ -23,6 +23,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DismissibleDrawerSheet import androidx.compose.material3.DismissibleDrawerSheet
import androidx.compose.material3.DismissibleNavigationDrawer import androidx.compose.material3.DismissibleNavigationDrawer
import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerState
@ -439,6 +440,14 @@ fun ChatRouterScreen(
emoteInfoSheetTarget = action.emoteId emoteInfoSheetTarget = action.emoteId
showEmoteInfoSheet = true showEmoteInfoSheet = true
} }
is Action.TopNavigate -> {
topNav.navigate(action.route)
}
is Action.ChatNavigate -> {
navController.navigate(action.route)
}
} }
} }
} }
@ -586,7 +595,11 @@ fun ChatRouterScreen(
) { ) {
UserInfoSheet( UserInfoSheet(
userId = userContextSheetTarget, userId = userContextSheetTarget,
serverId = userContextSheetServer serverId = userContextSheetServer,
dismissSheet = {
userContextSheetState.hide()
showUserContextSheet = false
}
) )
} }
} }
@ -954,6 +967,7 @@ fun Sidebar(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ChannelNavigator( fun ChannelNavigator(
navController: NavHostController, navController: NavHostController,
@ -1032,6 +1046,22 @@ fun ChannelNavigator(
) )
} }
} }
dialog("report/user/{userId}") { backStackEntry ->
val userId = backStackEntry.arguments?.getString("userId")
if (userId != null) {
AlertDialog(onDismissRequest = {
navController.popBackStack()
}) {
Text("Report user $userId")
Button(onClick = {
navController.popBackStack()
}) {
Text("Close")
}
}
}
}
} }
} }
} }

View File

@ -332,7 +332,7 @@ fun ProfileSettingsScreen(
}, },
label = { label = {
Text( Text(
text = stringResource(id = R.string.user_context_sheet_category_bio), text = stringResource(id = R.string.user_info_sheet_category_bio),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
) )
}, },

View File

@ -247,7 +247,11 @@ fun MemberListSheet(
) { ) {
UserInfoSheet( UserInfoSheet(
userId = userContextSheetTarget, userId = userContextSheetTarget,
serverId = serverId serverId = serverId,
dismissSheet = {
userContextSheetState.hide()
showUserContextSheet = false
}
) )
} }
} }

View File

@ -37,10 +37,15 @@ import chat.revolt.components.chat.RoleChip
import chat.revolt.components.generic.NonIdealState import chat.revolt.components.generic.NonIdealState
import chat.revolt.components.generic.WebMarkdown import chat.revolt.components.generic.WebMarkdown
import chat.revolt.components.screens.settings.RawUserOverview import chat.revolt.components.screens.settings.RawUserOverview
import chat.revolt.components.screens.settings.UserButtons
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun UserInfoSheet(userId: String, serverId: String? = null) { fun UserInfoSheet(
userId: String,
serverId: String? = null,
dismissSheet: suspend () -> Unit
) {
val user = RevoltAPI.userCache[userId] val user = RevoltAPI.userCache[userId]
val member = serverId?.let { RevoltAPI.members.getMember(it, userId) } val member = serverId?.let { RevoltAPI.members.getMember(it, userId) }
@ -73,12 +78,12 @@ fun UserInfoSheet(userId: String, serverId: String? = null) {
}, },
title = { title = {
Text( Text(
text = stringResource(R.string.user_context_sheet_user_not_found) text = stringResource(R.string.user_info_sheet_user_not_found)
) )
}, },
description = { description = {
Text( Text(
text = stringResource(R.string.user_context_sheet_user_not_found_description) text = stringResource(R.string.user_info_sheet_user_not_found_description)
) )
} }
) )
@ -94,9 +99,14 @@ fun UserInfoSheet(userId: String, serverId: String? = null) {
Column( Column(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp, top = 8.dp) modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp, top = 8.dp)
) { ) {
UserButtons(
user,
dismissSheet
)
member?.roles?.let { member?.roles?.let {
Text( Text(
text = stringResource(id = R.string.user_context_sheet_category_roles), text = stringResource(id = R.string.user_info_sheet_category_roles),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(vertical = 10.dp) modifier = Modifier.padding(vertical = 10.dp)
) )
@ -121,7 +131,7 @@ fun UserInfoSheet(userId: String, serverId: String? = null) {
} }
Text( Text(
text = stringResource(id = R.string.user_context_sheet_category_bio), text = stringResource(id = R.string.user_info_sheet_category_bio),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(vertical = 10.dp) modifier = Modifier.padding(vertical = 10.dp)
) )
@ -133,12 +143,12 @@ fun UserInfoSheet(userId: String, serverId: String? = null) {
) )
} else if (profile != null) { } else if (profile != null) {
Text( Text(
text = stringResource(id = R.string.user_context_sheet_bio_empty), text = stringResource(id = R.string.user_info_sheet_bio_empty),
color = LocalContentColor.current.copy(alpha = 0.6f) color = LocalContentColor.current.copy(alpha = 0.6f)
) )
} else if (profileNotFound) { } else if (profileNotFound) {
Text( Text(
text = stringResource(id = R.string.user_context_sheet_bio_not_found), text = stringResource(id = R.string.user_info_sheet_bio_not_found),
color = LocalContentColor.current.copy(alpha = 0.6f) color = LocalContentColor.current.copy(alpha = 0.6f)
) )
} else { } else {

View File

@ -270,12 +270,25 @@
<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="user_context_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_context_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>
<string name="user_context_sheet_category_bio">Bio</string> <string name="user_info_sheet_category_bio">Bio</string>
<string name="user_context_sheet_bio_empty">This user hasn\'t set a bio yet.</string> <string name="user_info_sheet_bio_empty">This user hasn\'t set a bio yet.</string>
<string name="user_context_sheet_bio_not_found">This user\'s bio could not be fetched. Please verify you share a server or are friends.</string> <string name="user_info_sheet_bio_not_found">This user\'s bio could not be fetched. Please verify you share a server or are friends.</string>
<string name="user_context_sheet_category_roles">Roles</string> <string name="user_info_sheet_category_roles">Roles</string>
<string name="user_info_sheet_add_friend">Add Friend</string>
<string name="user_info_sheet_send_message">Send Message</string>
<string name="user_info_sheet_remove_friend">Remove Friend</string>
<string name="user_info_sheet_accept_request">Accept</string>
<string name="user_info_sheet_decline_request">Decline</string>
<string name="user_info_sheet_cancel_request">Cancel Request</string>
<string name="user_info_sheet_block">Block</string>
<string name="user_info_sheet_unblock">Unblock</string>
<string name="user_info_sheet_edit_profile">Edit Profile</string>
<string name="user_info_sheet_report">Report</string>
<string name="user_info_sheet_copy_id">Copy ID</string>
<string name="user_info_sheet_failed_to_open_dm">Could not open DM with this user.</string>
<string name="add_server_sheet_title">Add a server</string> <string name="add_server_sheet_title">Add a server</string>
<string name="add_server_sheet_join_by_invite">Join by invite code or link</string> <string name="add_server_sheet_join_by_invite">Join by invite code or link</string>