feat: basic online member list
Signed-off-by: Infi <wingit@geist.ga>
This commit is contained in:
parent
1b3429f447
commit
b1b857a408
|
|
@ -5,21 +5,20 @@ import chat.revolt.api.schemas.Role
|
||||||
|
|
||||||
object Roles {
|
object Roles {
|
||||||
// lowest rank = highest role
|
// lowest rank = highest role
|
||||||
private fun resolveHighestRole(roles: List<Role?>): Role? {
|
private fun highestRoleWithPredicate(roles: List<Role?>, predicate: (Role) -> Boolean): Role? {
|
||||||
return roles.minByOrNull { role ->
|
|
||||||
role?.rank ?: 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun highestRoleWithColour(roles: List<Role?>): Role? {
|
|
||||||
return roles.filter { role ->
|
return roles.filter { role ->
|
||||||
role?.colour != null
|
predicate(role!!)
|
||||||
}.minByOrNull { role ->
|
}.minByOrNull { role ->
|
||||||
role?.rank ?: 0.0
|
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 server = RevoltAPI.serverCache[serverId] ?: return null
|
||||||
val member = RevoltAPI.members.getMember(serverId, userId) ?: return null
|
val member = RevoltAPI.members.getMember(serverId, userId) ?: return null
|
||||||
|
|
||||||
|
|
@ -27,10 +26,17 @@ object Roles {
|
||||||
server.roles?.get(roleId)
|
server.roles?.get(roleId)
|
||||||
} ?: return null
|
} ?: return null
|
||||||
|
|
||||||
return if (withColour) {
|
return highestRoleWithPredicate(roles) { role ->
|
||||||
highestRoleWithColour(roles)
|
val hoistPredicate = if (hoisted) (role.hoist == true) else true
|
||||||
} else {
|
val colourPredicate = if (withColour) (role.colour != null) else true
|
||||||
resolveHighestRole(roles)
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -13,10 +13,17 @@ import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.List
|
import androidx.compose.material.icons.filled.List
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
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.generic.SheetClickable
|
||||||
import chat.revolt.components.screens.chat.ChannelSheetHeader
|
import chat.revolt.components.screens.chat.ChannelSheetHeader
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChannelInfoSheet(
|
fun ChannelInfoSheet(
|
||||||
channelId: String,
|
channelId: String,
|
||||||
) {
|
) {
|
||||||
val channel = RevoltAPI.channelCache[channelId]
|
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) {
|
if (channel == null) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -63,8 +88,7 @@ fun ChannelInfoSheet(
|
||||||
modifier = Modifier.padding(bottom = 10.dp)
|
modifier = Modifier.padding(bottom = 10.dp)
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = channel.description
|
text = if (channel.description.isNullOrBlank()) stringResource(id = R.string.channel_info_sheet_description_empty) else channel.description,
|
||||||
?: stringResource(id = R.string.channel_info_sheet_description_empty),
|
|
||||||
modifier = Modifier.padding(bottom = 10.dp)
|
modifier = Modifier.padding(bottom = 10.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -91,7 +115,7 @@ fun ChannelInfoSheet(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
memberListSheetShown = true
|
||||||
}
|
}
|
||||||
|
|
||||||
SheetClickable(
|
SheetClickable(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -51,7 +51,7 @@ fun UserContextSheet(
|
||||||
LaunchedEffect(user) {
|
LaunchedEffect(user) {
|
||||||
try {
|
try {
|
||||||
user?.id?.let { fetchUserProfile(it) }?.let { profile = it }
|
user?.id?.let { fetchUserProfile(it) }?.let { profile = it }
|
||||||
} catch (e: Exception) {
|
} catch (e: Error) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,13 @@
|
||||||
<string name="reconnecting">Reconnecting…</string>
|
<string name="reconnecting">Reconnecting…</string>
|
||||||
<string name="reconnected">Reconnected</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">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="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>
|
<string name="tap_to_retry">Tap to retry</string>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue