diff --git a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt index bcc6b3aa..1602ca48 100644 --- a/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt +++ b/app/src/main/java/chat/revolt/api/realtime/RealtimeSocket.kt @@ -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.PingFrame import chat.revolt.api.routes.server.fetchMember +import chat.revolt.api.schemas.Channel import chat.revolt.api.settings.GlobalState import chat.revolt.api.settings.SyncedSettings import io.ktor.client.plugins.websocket.ws @@ -304,6 +305,18 @@ object RealtimeSocket { 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" -> { val channelAckFrame = RevoltJson.decodeFromString(ChannelAckFrame.serializer(), rawFrame) diff --git a/app/src/main/java/chat/revolt/api/routes/user/DirectMessaging.kt b/app/src/main/java/chat/revolt/api/routes/user/DirectMessaging.kt new file mode 100644 index 00000000..d566ae14 --- /dev/null +++ b/app/src/main/java/chat/revolt/api/routes/user/DirectMessaging.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/routes/user/Relationships.kt b/app/src/main/java/chat/revolt/api/routes/user/Relationships.kt index bb03a0ac..618b3399 100644 --- a/app/src/main/java/chat/revolt/api/routes/user/Relationships.kt +++ b/app/src/main/java/chat/revolt/api/routes/user/Relationships.kt @@ -1,14 +1,16 @@ 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.delete +import io.ktor.client.request.post import io.ktor.client.request.put +import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType import kotlinx.serialization.SerializationException -import kotlin.collections.set suspend fun blockUser(userId: String) { val response = RevoltHttp.put("/users/$userId/block") @@ -20,9 +22,6 @@ suspend fun blockUser(userId: String) { } catch (e: SerializationException) { // Not an error } - - val user = RevoltAPI.userCache[userId] ?: return - RevoltAPI.userCache[userId] = user.copy(relationship = "Blocked") } suspend fun unblockUser(userId: String) { @@ -35,9 +34,33 @@ suspend fun unblockUser(userId: String) { } catch (e: SerializationException) { // Not an error } +} - val user = RevoltAPI.userCache[userId] ?: return - RevoltAPI.userCache[userId] = user.copy(relationship = "None") +suspend fun friendUser(username: String) { + 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) { @@ -50,7 +73,4 @@ suspend fun unfriendUser(userId: String) { } catch (e: SerializationException) { // Not an error } - - val user = RevoltAPI.userCache[userId] ?: return - RevoltAPI.userCache[userId] = user.copy(relationship = "None") } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt b/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt index b798bc38..75844a88 100644 --- a/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt +++ b/app/src/main/java/chat/revolt/callbacks/ActionChannel.kt @@ -7,6 +7,8 @@ sealed class Action { data class SwitchChannel(val channelId: String) : Action() data class LinkInfo(val url: 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( diff --git a/app/src/main/java/chat/revolt/components/screens/settings/UserButtons.kt b/app/src/main/java/chat/revolt/components/screens/settings/UserButtons.kt new file mode 100644 index 00000000..950d1efb --- /dev/null +++ b/app/src/main/java/chat/revolt/components/screens/settings/UserButtons.kt @@ -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() + } + } + } + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index 99b7b68c..bc222781 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button import androidx.compose.material3.DismissibleDrawerSheet import androidx.compose.material3.DismissibleNavigationDrawer import androidx.compose.material3.DrawerState @@ -439,6 +440,14 @@ fun ChatRouterScreen( emoteInfoSheetTarget = action.emoteId showEmoteInfoSheet = true } + + is Action.TopNavigate -> { + topNav.navigate(action.route) + } + + is Action.ChatNavigate -> { + navController.navigate(action.route) + } } } } @@ -586,7 +595,11 @@ fun ChatRouterScreen( ) { UserInfoSheet( userId = userContextSheetTarget, - serverId = userContextSheetServer + serverId = userContextSheetServer, + dismissSheet = { + userContextSheetState.hide() + showUserContextSheet = false + } ) } } @@ -954,6 +967,7 @@ fun Sidebar( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChannelNavigator( 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") + } + } + } + } } } } diff --git a/app/src/main/java/chat/revolt/screens/settings/ProfileSettngsScreen.kt b/app/src/main/java/chat/revolt/screens/settings/ProfileSettngsScreen.kt index f95b4e2f..6e079cfa 100644 --- a/app/src/main/java/chat/revolt/screens/settings/ProfileSettngsScreen.kt +++ b/app/src/main/java/chat/revolt/screens/settings/ProfileSettngsScreen.kt @@ -205,7 +205,7 @@ class ProfileSettingsScreenViewModel @Inject constructor(@ApplicationContext val fun saveBio() { viewModelScope.launch { patchSelf(bio = pendingProfile?.content) - + fetchUserProfile(RevoltAPI.selfId!!).let { currentProfile = it pendingProfile = it @@ -332,7 +332,7 @@ fun ProfileSettingsScreen( }, label = { 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, ) }, diff --git a/app/src/main/java/chat/revolt/sheets/MemberListSheet.kt b/app/src/main/java/chat/revolt/sheets/MemberListSheet.kt index cbb1e247..9dcd0083 100644 --- a/app/src/main/java/chat/revolt/sheets/MemberListSheet.kt +++ b/app/src/main/java/chat/revolt/sheets/MemberListSheet.kt @@ -247,7 +247,11 @@ fun MemberListSheet( ) { UserInfoSheet( userId = userContextSheetTarget, - serverId = serverId + serverId = serverId, + dismissSheet = { + userContextSheetState.hide() + showUserContextSheet = false + } ) } } diff --git a/app/src/main/java/chat/revolt/sheets/UserInfoSheet.kt b/app/src/main/java/chat/revolt/sheets/UserInfoSheet.kt index f2141946..a6e568e8 100644 --- a/app/src/main/java/chat/revolt/sheets/UserInfoSheet.kt +++ b/app/src/main/java/chat/revolt/sheets/UserInfoSheet.kt @@ -37,10 +37,15 @@ import chat.revolt.components.chat.RoleChip import chat.revolt.components.generic.NonIdealState import chat.revolt.components.generic.WebMarkdown import chat.revolt.components.screens.settings.RawUserOverview +import chat.revolt.components.screens.settings.UserButtons @OptIn(ExperimentalLayoutApi::class) @Composable -fun UserInfoSheet(userId: String, serverId: String? = null) { +fun UserInfoSheet( + userId: String, + serverId: String? = null, + dismissSheet: suspend () -> Unit +) { val user = RevoltAPI.userCache[userId] val member = serverId?.let { RevoltAPI.members.getMember(it, userId) } @@ -73,12 +78,12 @@ fun UserInfoSheet(userId: String, serverId: String? = null) { }, title = { Text( - text = stringResource(R.string.user_context_sheet_user_not_found) + text = stringResource(R.string.user_info_sheet_user_not_found) ) }, description = { 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( modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp, top = 8.dp) ) { + UserButtons( + user, + dismissSheet + ) + member?.roles?.let { 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, modifier = Modifier.padding(vertical = 10.dp) ) @@ -121,7 +131,7 @@ fun UserInfoSheet(userId: String, serverId: String? = null) { } 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, modifier = Modifier.padding(vertical = 10.dp) ) @@ -133,12 +143,12 @@ fun UserInfoSheet(userId: String, serverId: String? = null) { ) } else if (profile != null) { 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) ) } else if (profileNotFound) { 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) ) } else { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84ab1f4e..086a6c83 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -270,12 +270,25 @@ Stay Leave Silently - Can\'t resolve this user - This user may have been deleted or you may not have permission to view them. - Bio - This user hasn\'t set a bio yet. - This user\'s bio could not be fetched. Please verify you share a server or are friends. - Roles + Can\'t resolve this user + This user may have been deleted or you may not have permission to view them. + Bio + This user hasn\'t set a bio yet. + This user\'s bio could not be fetched. Please verify you share a server or are friends. + Roles + Add Friend + Send Message + Remove Friend + Accept + Decline + Cancel Request + Block + Unblock + Edit Profile + Report + Copy ID + Could not open DM with this user. + Add a server Join by invite code or link