diff --git a/app/src/main/java/chat/revolt/activities/MainActivity.kt b/app/src/main/java/chat/revolt/activities/MainActivity.kt index a60ba71d..a0caf671 100644 --- a/app/src/main/java/chat/revolt/activities/MainActivity.kt +++ b/app/src/main/java/chat/revolt/activities/MainActivity.kt @@ -51,6 +51,7 @@ import chat.revolt.screens.SplashScreen import chat.revolt.screens.about.AboutScreen import chat.revolt.screens.about.AttributionScreen import chat.revolt.screens.chat.ChatRouterScreen +import chat.revolt.screens.create.CreateGroupScreen import chat.revolt.screens.labs.LabsRootScreen import chat.revolt.screens.login.LoginGreetingScreen import chat.revolt.screens.login.LoginScreen @@ -365,6 +366,8 @@ fun AppEntrypoint( ) } + composable("create/group") { CreateGroupScreen(navController) } + composable("discover") { DiscoverScreen(navController) } composable("settings") { SettingsScreen(navController) } diff --git a/app/src/main/java/chat/revolt/api/internals/FriendRequests.kt b/app/src/main/java/chat/revolt/api/internals/FriendRequests.kt index 5d70a146..dadad4b3 100644 --- a/app/src/main/java/chat/revolt/api/internals/FriendRequests.kt +++ b/app/src/main/java/chat/revolt/api/internals/FriendRequests.kt @@ -30,7 +30,7 @@ object FriendRequests { fun getFriends(excludeOnline: Boolean = false): List { return RevoltAPI.userCache.values.filter { user -> - user.relationship == "Friend" && (excludeOnline && user.online == false) + user.relationship == "Friend" && if (excludeOnline) user.online == false else true } } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/api/routes/channel/GroupDM.kt b/app/src/main/java/chat/revolt/api/routes/channel/GroupDM.kt new file mode 100644 index 00000000..4419bfef --- /dev/null +++ b/app/src/main/java/chat/revolt/api/routes/channel/GroupDM.kt @@ -0,0 +1,40 @@ +package chat.revolt.api.routes.channel + +import chat.revolt.api.RevoltError +import chat.revolt.api.RevoltHttp +import chat.revolt.api.RevoltJson +import chat.revolt.api.schemas.Channel +import chat.revolt.screens.create.MAX_ADDABLE_PEOPLE_IN_GROUP +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.serialization.Serializable +import kotlinx.serialization.SerializationException + +@Serializable +data class CreateGroupDMBody( + val name: String, + val users: List +) + +suspend fun createGroupDM(name: String, members: List): Channel { + if (members.size > MAX_ADDABLE_PEOPLE_IN_GROUP) { + throw Exception("Too many members, maximum is $MAX_ADDABLE_PEOPLE_IN_GROUP") + } + + val response = RevoltHttp.post("/channels/create") { + contentType(ContentType.Application.Json) + setBody(CreateGroupDMBody(name, members)) + }.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/components/chat/MemberListItem.kt b/app/src/main/java/chat/revolt/components/chat/MemberListItem.kt index ead781d7..9e70c251 100644 --- a/app/src/main/java/chat/revolt/components/chat/MemberListItem.kt +++ b/app/src/main/java/chat/revolt/components/chat/MemberListItem.kt @@ -24,6 +24,7 @@ fun MemberListItem( serverId: String?, userId: String, modifier: Modifier = Modifier, + trailingContent: @Composable (() -> Unit)? = null, ) { val highestColourRole = serverId?.let { user?.id?.let { userId -> @@ -79,5 +80,6 @@ fun MemberListItem( ) ) }, + trailingContent = trailingContent ) } \ 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 2439eb27..45a1516d 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -1038,6 +1038,7 @@ fun ChannelNavigator( toggleDrawer() } FriendsScreen( + topNav = topNav, useDrawer = useDrawer, onDrawerClicked = toggleDrawer, ) diff --git a/app/src/main/java/chat/revolt/screens/chat/views/FriendsScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/FriendsScreen.kt index fdd9f901..0b91b9fd 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/FriendsScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/FriendsScreen.kt @@ -25,8 +25,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.navigation.NavController import chat.revolt.R import chat.revolt.api.internals.FriendRequests import chat.revolt.api.routes.user.unfriendUser @@ -40,7 +42,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun FriendsScreen(useDrawer: Boolean, onDrawerClicked: () -> Unit) { +fun FriendsScreen(topNav: NavController, useDrawer: Boolean, onDrawerClicked: () -> Unit) { var overflowMenuShown by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() @@ -67,6 +69,14 @@ fun FriendsScreen(useDrawer: Boolean, onDrawerClicked: () -> Unit) { } }, actions = { + IconButton(onClick = { + topNav.navigate("create/group") + }) { + Icon( + painter = painterResource(R.drawable.ic_account_multiple_plus_24dp), + contentDescription = stringResource(R.string.frends_new_group) + ) + } IconButton(onClick = { overflowMenuShown = true }) { diff --git a/app/src/main/java/chat/revolt/screens/create/CreateGroupScreen.kt b/app/src/main/java/chat/revolt/screens/create/CreateGroupScreen.kt new file mode 100644 index 00000000..2ade3e7a --- /dev/null +++ b/app/src/main/java/chat/revolt/screens/create/CreateGroupScreen.kt @@ -0,0 +1,225 @@ +package chat.revolt.screens.create + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import chat.revolt.R +import chat.revolt.activities.RevoltTweenFloat +import chat.revolt.api.RevoltAPI +import chat.revolt.api.internals.FriendRequests +import chat.revolt.api.routes.channel.createGroupDM +import chat.revolt.callbacks.Action +import chat.revolt.callbacks.ActionChannel +import chat.revolt.components.chat.MemberListItem +import kotlinx.coroutines.launch + +const val MAX_PEOPLE_IN_GROUP = 50 +const val MAX_ADDABLE_PEOPLE_IN_GROUP = MAX_PEOPLE_IN_GROUP - 1 + +class CreateGroupScreenViewModel : ViewModel() { + var groupName by mutableStateOf("") + var groupMembers = mutableStateListOf() + var friendSearchQuery by mutableStateOf("") + var friendsFilteredBySearch = mutableStateListOf() + var error by mutableStateOf(null) + + fun updateFriendSearchQuery(query: String) { + friendSearchQuery = query + filterFriends() + } + + fun filterFriends() { + friendsFilteredBySearch.clear() + friendsFilteredBySearch.addAll(FriendRequests.getFriends().filter { + if (friendSearchQuery.isBlank()) { + return@filter true + } + + if (it.displayName == null || it.username == null) { + return@filter false + } + + it.displayName.contains(friendSearchQuery, ignoreCase = true) || + it.username.contains(friendSearchQuery, ignoreCase = true) + }.map { it.id!! }) + } + + fun createGroup(popBackStack: () -> Unit) { + if (groupMembers.size > MAX_ADDABLE_PEOPLE_IN_GROUP) { + error = "Too many members, maximum is $MAX_ADDABLE_PEOPLE_IN_GROUP" + return + } + + try { + error = null + viewModelScope.launch { + val channel = createGroupDM(groupName, groupMembers) + popBackStack() + channel.id?.let { ActionChannel.send(Action.SwitchChannel(it)) } + } + } catch (e: Exception) { + error = e.message + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateGroupScreen( + navController: NavController, + viewModel: CreateGroupScreenViewModel = viewModel() +) { + LaunchedEffect(Unit) { + viewModel.filterFriends() + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(R.string.create_group), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = { + navController.popBackStack() + }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(id = R.string.back) + ) + } + } + ) + }, + floatingActionButton = { + AnimatedVisibility( + visible = viewModel.groupName.isNotBlank() && viewModel.groupMembers.isNotEmpty(), + enter = scaleIn(animationSpec = RevoltTweenFloat), + exit = scaleOut(animationSpec = RevoltTweenFloat) + ) { + FloatingActionButton(onClick = { viewModel.createGroup(navController::popBackStack) }) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(R.string.create_group_action) + ) + } + } + } + ) { pv -> + Column( + Modifier + .padding(pv) + .imePadding() + ) { + Text( + text = stringResource(R.string.create_group_description, MAX_PEOPLE_IN_GROUP), + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + AnimatedVisibility(visible = viewModel.error?.isNotBlank() ?: false) { + Text( + text = viewModel.error ?: "", + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) + } + TextField( + value = viewModel.groupName, + onValueChange = { viewModel.groupName = it }, + label = { Text(stringResource(R.string.create_group_name)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 32.dp) + ) + OutlinedTextField( + value = viewModel.friendSearchQuery, + onValueChange = { viewModel.updateFriendSearchQuery(it) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + }, + label = { Text(stringResource(R.string.create_group_search)) }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + ) + LazyColumn(contentPadding = PaddingValues(bottom = 78.0.dp)) { + items(viewModel.friendsFilteredBySearch.size) { index -> + val friend = RevoltAPI.userCache[viewModel.friendsFilteredBySearch[index]] + ?: return@items + val isMember = viewModel.groupMembers.contains(friend.id) + + MemberListItem( + member = null, + user = friend, + serverId = null, + userId = friend.id!!, + modifier = Modifier.clickable { + if (isMember) { + viewModel.groupMembers.remove(friend.id) + } else { + if (viewModel.groupMembers.size < MAX_ADDABLE_PEOPLE_IN_GROUP) { + viewModel.groupMembers.add(friend.id) + } + } + }, + trailingContent = { + Checkbox( + checked = isMember, + onCheckedChange = null, + enabled = (isMember.not() && viewModel.groupMembers.size >= MAX_ADDABLE_PEOPLE_IN_GROUP).not() + ) + } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_account_multiple_plus_24dp.xml b/app/src/main/res/drawable/ic_account_multiple_plus_24dp.xml new file mode 100644 index 00000000..8d148e3c --- /dev/null +++ b/app/src/main/res/drawable/ic_account_multiple_plus_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47ff366b..f074a5bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -129,6 +129,13 @@ All Blocked Clear all incoming requests + New Group + + Create a new group + Round up your crew in a group chat. You can add up to %1$d people. + Group Name + Search Friends + Create Add server Discover