diff --git a/app/src/main/java/chat/revolt/api/internals/Roles.kt b/app/src/main/java/chat/revolt/api/internals/Roles.kt index f7ee2d59..9174f1d7 100644 --- a/app/src/main/java/chat/revolt/api/internals/Roles.kt +++ b/app/src/main/java/chat/revolt/api/internals/Roles.kt @@ -5,21 +5,20 @@ import chat.revolt.api.schemas.Role object Roles { // lowest rank = highest role - private fun resolveHighestRole(roles: List): Role? { - return roles.minByOrNull { role -> - role?.rank ?: 0.0 - } - } - - private fun highestRoleWithColour(roles: List): Role? { + private fun highestRoleWithPredicate(roles: List, predicate: (Role) -> Boolean): Role? { return roles.filter { role -> - role?.colour != null + predicate(role!!) }.minByOrNull { role -> role?.rank ?: 0.0 } } - fun resolveHighestRole(serverId: String, userId: String, withColour: Boolean = false): Role? { + fun resolveHighestRole( + serverId: String, + userId: String, + withColour: Boolean = false, + hoisted: Boolean = false + ): Role? { val server = RevoltAPI.serverCache[serverId] ?: return null val member = RevoltAPI.members.getMember(serverId, userId) ?: return null @@ -27,10 +26,17 @@ object Roles { server.roles?.get(roleId) } ?: return null - return if (withColour) { - highestRoleWithColour(roles) - } else { - resolveHighestRole(roles) + return highestRoleWithPredicate(roles) { role -> + val hoistPredicate = if (hoisted) (role.hoist == true) else true + val colourPredicate = if (withColour) (role.colour != null) else true + + hoistPredicate && colourPredicate } } + + fun inOrder(serverId: String, predicate: (Role) -> Boolean): List { + val server = RevoltAPI.serverCache[serverId] ?: return emptyList() + + return server.roles?.values?.filter(predicate)?.sortedBy { it.rank } ?: emptyList() + } } \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/sheets/ChannelInfoSheet.kt b/app/src/main/java/chat/revolt/sheets/ChannelInfoSheet.kt index f90ab5c0..ba0bb40a 100644 --- a/app/src/main/java/chat/revolt/sheets/ChannelInfoSheet.kt +++ b/app/src/main/java/chat/revolt/sheets/ChannelInfoSheet.kt @@ -13,10 +13,17 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.List import androidx.compose.material.icons.filled.Notifications import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -27,11 +34,29 @@ import chat.revolt.api.schemas.ChannelType import chat.revolt.components.generic.SheetClickable import chat.revolt.components.screens.chat.ChannelSheetHeader +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChannelInfoSheet( channelId: String, ) { val channel = RevoltAPI.channelCache[channelId] + var memberListSheetShown by remember { mutableStateOf(false) } + + if (memberListSheetShown) { + val memberListSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + sheetState = memberListSheetState, + onDismissRequest = { + memberListSheetShown = false + } + ) { + MemberListSheet( + serverId = channel?.server ?: "" + ) + } + } + if (channel == null) { Box( modifier = Modifier @@ -63,8 +88,7 @@ fun ChannelInfoSheet( modifier = Modifier.padding(bottom = 10.dp) ) Text( - text = channel.description - ?: stringResource(id = R.string.channel_info_sheet_description_empty), + text = if (channel.description.isNullOrBlank()) stringResource(id = R.string.channel_info_sheet_description_empty) else channel.description, modifier = Modifier.padding(bottom = 10.dp) ) @@ -91,7 +115,7 @@ fun ChannelInfoSheet( ) } ) { - + memberListSheetShown = true } SheetClickable( diff --git a/app/src/main/java/chat/revolt/sheets/MemberListSheet.kt b/app/src/main/java/chat/revolt/sheets/MemberListSheet.kt new file mode 100644 index 00000000..865e67c8 --- /dev/null +++ b/app/src/main/java/chat/revolt/sheets/MemberListSheet.kt @@ -0,0 +1,297 @@ +package chat.revolt.sheets + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.surfaceColorAtElevation +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.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import chat.revolt.R +import chat.revolt.api.RevoltAPI +import chat.revolt.api.internals.Roles +import chat.revolt.api.internals.WebCompat +import chat.revolt.api.internals.solidColor +import chat.revolt.api.routes.server.fetchMembers +import chat.revolt.api.schemas.Member +import chat.revolt.api.schemas.User +import chat.revolt.components.generic.PageHeader +import chat.revolt.components.generic.UserAvatar +import chat.revolt.components.generic.presenceFromStatus +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.launch +import javax.inject.Inject + +val DO_NOT_FETCH_OFFLINE_MEMBERS_SERVERS = listOf( + "01F7ZSBSFHQ8TA81725KQCSDDP" // Revolt Lounge +) + +sealed class MemberListItem { + data class MemberItem(val member: Member) : MemberListItem() + data class CategoryItem(val category: String, val count: Int) : MemberListItem() +} + +@HiltViewModel +@SuppressLint("StaticFieldLeak") +class MemberListSheetViewModel @Inject constructor( + @ApplicationContext private val context: Context +) : ViewModel() { + val fullItemList = mutableStateListOf() + + fun fetchMemberList( + serverId: String + ) { + viewModelScope.launch { + val memberList = fetchMembers( + serverId = serverId, + includeOffline = serverId !in DO_NOT_FETCH_OFFLINE_MEMBERS_SERVERS + ).members + + val categories = mutableMapOf>() + + val offlineCategoryName = context.getString(R.string.status_offline) + val defaultCategoryName = context.getString(R.string.status_online) + + memberList.forEach { member -> + val user = RevoltAPI.userCache[member.id.user] ?: run { + Log.w( + "MemberListSheet", + "User ${member.id.user} found in member list of server $serverId but not in user cache" + ) + return@forEach + } + + if (user.online == false) { + categories[offlineCategoryName] = + (categories[offlineCategoryName] ?: listOf()) + member + return@forEach + } + + val highestHoistedRole = + Roles.resolveHighestRole(serverId, member.id.user, hoisted = true) + + val category = if (highestHoistedRole != null) { + highestHoistedRole.name ?: context.getString(R.string.unknown) + } else { + defaultCategoryName + } + + categories[category] = (categories[category] ?: listOf()) + member + } + + fullItemList.clear() + + // Hoisted roles + Roles.inOrder(serverId) { it.hoist == true }.forEach { role -> + val members = categories[role.name] ?: return@forEach + fullItemList.add(MemberListItem.CategoryItem(role.name ?: "", members.size)) + members.forEach { member -> + fullItemList.add(MemberListItem.MemberItem(member)) + } + } + + // Online + fullItemList.add( + MemberListItem.CategoryItem( + defaultCategoryName, + categories[defaultCategoryName]?.size ?: 0 + ) + ) + categories[defaultCategoryName]?.forEach { member -> + fullItemList.add(MemberListItem.MemberItem(member)) + } + + // Offline + if (categories[offlineCategoryName].isNullOrEmpty()) return@launch + fullItemList.add( + MemberListItem.CategoryItem( + offlineCategoryName, + categories[offlineCategoryName]?.size ?: 0 + ) + ) + categories[offlineCategoryName]?.forEach { member -> + fullItemList.add(MemberListItem.MemberItem(member)) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun MemberListSheet( + serverId: String, + viewModel: MemberListSheetViewModel = hiltViewModel() +) { + var showUserContextSheet by remember { mutableStateOf(false) } + var userContextSheetTarget by remember { mutableStateOf("") } + + LaunchedEffect(serverId) { + viewModel.fetchMemberList(serverId) + } + + if (showUserContextSheet) { + val userContextSheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + sheetState = userContextSheetState, + onDismissRequest = { + showUserContextSheet = false + }) { + UserContextSheet( + userId = userContextSheetTarget, + serverId = serverId + ) + } + } + + if (viewModel.fullItemList.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + ) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + return + } + + Column { + PageHeader(text = "Members") + + LazyColumn { + viewModel.fullItemList.forEachIndexed { index, item -> + when (item) { + is MemberListItem.CategoryItem -> stickyHeader(key = "${item.category}-$index") { + MemberListCategory(text = item.category, count = item.count) + } + + is MemberListItem.MemberItem -> item(key = item.member.id.user) { + MemberListMember( + member = item.member, + user = RevoltAPI.userCache[item.member.id.user]!!, + serverId = serverId, + onSelectUser = { + userContextSheetTarget = it + showUserContextSheet = true + } + ) + } + } + } + } + } +} + +@Composable +fun MemberListMember( + member: Member, + user: User, + serverId: String, + onSelectUser: (String) -> Unit +) { + val highestColourRole = Roles.resolveHighestRole(serverId, member.id.user, true) + val colour = highestColourRole?.colour?.let { WebCompat.parseColour(it) } + ?: Brush.solidColor(LocalContentColor.current) + + Row( + modifier = Modifier + .clickable { + onSelectUser(member.id.user) + } + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + UserAvatar( + username = member.nickname + ?: user.displayName + ?: user.username + ?: user.id!!, + avatar = user.avatar, + userId = user.id!!, + presence = presenceFromStatus( + user.status?.presence ?: "", + user.online ?: false + ) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = member.nickname + ?: user.displayName + ?: user.username + ?: user.id, + style = LocalTextStyle.current.copy( + fontWeight = FontWeight.Bold, + brush = colour + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +fun MemberListCategory( + text: String, + count: Int +) { + Text( + text = AnnotatedString.Builder().apply { + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append(text) + pop() + + pushStyle( + SpanStyle( + fontWeight = FontWeight.Medium, + fontSize = LocalTextStyle.current.fontSize * 0.8, + color = LocalContentColor.current.copy(alpha = 0.6f) + ) + ) + append("—$count") + pop() + }.toAnnotatedString(), + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/sheets/UserContextSheet.kt b/app/src/main/java/chat/revolt/sheets/UserContextSheet.kt index 0a7fc1e6..cde6128e 100644 --- a/app/src/main/java/chat/revolt/sheets/UserContextSheet.kt +++ b/app/src/main/java/chat/revolt/sheets/UserContextSheet.kt @@ -51,7 +51,7 @@ fun UserContextSheet( LaunchedEffect(user) { try { user?.id?.let { fetchUserProfile(it) }?.let { profile = it } - } catch (e: Exception) { + } catch (e: Error) { e.printStackTrace() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cbaa88d8..687734a9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -167,6 +167,13 @@ Reconnecting… Reconnected + Online + Idle + Focus + Do Not Disturb + Offline + Invisible + No connection You are not connected to the internet. Please check your connection and try again. Tap to retry