feat: basic online member list

Signed-off-by: Infi <wingit@geist.ga>
This commit is contained in:
Infi 2023-08-27 17:13:11 +05:00
parent 1b3429f447
commit b1b857a408
5 changed files with 351 additions and 17 deletions

View File

@ -5,21 +5,20 @@ import chat.revolt.api.schemas.Role
object Roles {
// lowest rank = highest role
private fun resolveHighestRole(roles: List<Role?>): Role? {
return roles.minByOrNull { role ->
role?.rank ?: 0.0
}
}
private fun highestRoleWithColour(roles: List<Role?>): Role? {
private fun highestRoleWithPredicate(roles: List<Role?>, 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<Role> {
val server = RevoltAPI.serverCache[serverId] ?: return emptyList()
return server.roles?.values?.filter(predicate)?.sortedBy { it.rank } ?: emptyList()
}
}

View File

@ -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(

View File

@ -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<MemberListItem>()
fun fetchMemberList(
serverId: String
) {
viewModelScope.launch {
val memberList = fetchMembers(
serverId = serverId,
includeOffline = serverId !in DO_NOT_FETCH_OFFLINE_MEMBERS_SERVERS
).members
val categories = mutableMapOf<String, List<Member>>()
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)
)
}

View File

@ -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()
}
}

View File

@ -167,6 +167,13 @@
<string name="reconnecting">Reconnecting…</string>
<string name="reconnected">Reconnected</string>
<string name="status_online">Online</string>
<string name="status_idle">Idle</string>
<string name="status_focus">Focus</string>
<string name="status_dnd">Do Not Disturb</string>
<string name="status_offline">Offline</string>
<string name="status_invisible">Invisible</string>
<string name="no_connection">No connection</string>
<string name="no_connection_message">You are not connected to the internet. Please check your connection and try again.</string>
<string name="tap_to_retry">Tap to retry</string>