Sort the room member list and display member roles (#2412)

* Sort the room member list and display member roles

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa
2024-02-19 16:03:36 +01:00
committed by GitHub
parent 615ce72131
commit 87823fe8a4
22 changed files with 235 additions and 33 deletions

3
changelog.d/2256.feature Normal file
View File

@@ -0,0 +1,3 @@
Add moderation to rooms:
- Sort member in room member list by powerlevel, display their roles.

View File

@@ -27,7 +27,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@@ -38,6 +37,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
@@ -431,7 +431,7 @@ class InviteListPresenterTests {
avatarUrl = null,
isDirect = false,
lastMessage = null,
inviter = RoomMember(
inviter = aRoomMember(
userId = A_USER_ID,
displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL,
@@ -458,7 +458,7 @@ class InviteListPresenterTests {
avatarUrl = null,
isDirect = true,
lastMessage = null,
inviter = RoomMember(
inviter = aRoomMember(
userId = A_USER_ID,
displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL,

View File

@@ -152,6 +152,7 @@ internal fun MentionSuggestionsPickerView_Preview() {
powerLevel = 0L,
normalizedPowerLevel = 0L,
isIgnored = false,
role = RoomMember.Role.USER,
)
MentionSuggestionsPickerView(
roomId = RoomId("!room:matrix.org"),

View File

@@ -103,5 +103,6 @@ private fun createDefaultRoomMemberForTyping(userId: UserId): RoomMember {
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
role = RoomMember.Role.USER,
)
}

View File

@@ -98,5 +98,6 @@ internal fun aTypingRoomMember(
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
role = RoomMember.Role.USER,
)
}

View File

@@ -26,8 +26,6 @@ import io.element.android.libraries.featureflag.test.InMemorySessionPreferencesS
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
@@ -178,7 +176,6 @@ class TypingNotificationPresenterTest {
@Test
fun `present - reserveSpace becomes true once we get the first typing notification with room members`() = runTest {
val aDefaultRoomMember = createDefaultRoomMember(A_USER_ID_2)
val room = FakeMatrixRoom()
val presenter = createPresenter(matrixRoom = room)
moleculeFlow(RecompositionMode.Immediate) {
@@ -216,27 +213,17 @@ class TypingNotificationPresenterTest {
private fun createDefaultRoomMember(
userId: UserId,
) = RoomMember(
) = aTypingRoomMember(
userId = userId,
displayName = null,
avatarUrl = null,
membership = RoomMembershipState.JOIN,
isNameAmbiguous = false,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
)
private fun createKnownRoomMember(
userId: UserId,
) = RoomMember(
) = aTypingRoomMember(
userId = userId,
displayName = "Alice Doe",
avatarUrl = "an_avatar_url",
membership = RoomMembershipState.JOIN,
isNameAmbiguous = true,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
)
}

View File

@@ -55,6 +55,7 @@ fun aDmRoomMember(
powerLevel: Long = 0,
normalizedPowerLevel: Long = powerLevel,
isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.USER,
) = RoomMember(
userId = userId,
displayName = displayName,
@@ -64,6 +65,7 @@ fun aDmRoomMember(
powerLevel = powerLevel,
normalizedPowerLevel = normalizedPowerLevel,
isIgnored = isIgnored,
role = role,
)
fun aRoomDetailsState() = RoomDetailsState(

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.impl.members
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.ui.room.sortingName
import java.text.Collator
// Comparator used to sort room members by power level (descending) and then by name (ascending)
internal class PowerLevelRoomMemberComparator : Comparator<RoomMember> {
// Used to simplify and compare unicode and ASCII chars (á == a)
private val collator = Collator.getInstance().apply {
decomposition = Collator.CANONICAL_DECOMPOSITION
}
override fun compare(o1: RoomMember, o2: RoomMember): Int {
return when {
o1.powerLevel > o2.powerLevel -> return -1
o1.powerLevel < o2.powerLevel -> return 1
else -> {
collator.compare(o1.sortingName(), o2.sortingName())
}
}
}
}

View File

@@ -66,7 +66,9 @@ class RoomMemberListPresenter @Inject constructor(
roomMembers = AsyncData.Success(
RoomMembers(
invited = members.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(),
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator())
.toImmutableList(),
)
)
}
@@ -84,7 +86,9 @@ class RoomMemberListPresenter @Inject constructor(
SearchBarResultState.Results(
RoomMembers(
invited = results.getOrDefault(RoomMembershipState.INVITE, emptyList()).toImmutableList(),
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()).toImmutableList(),
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator())
.toImmutableList(),
)
)
}

View File

@@ -31,7 +31,7 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMember
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor(), aWalter()),
joined = persistentListOf(anAlice(), aBob()),
joined = persistentListOf(anAlice(), aBob(), aWalter()),
)
)
),
@@ -79,6 +79,7 @@ fun aRoomMember(
powerLevel: Long = 0L,
normalizedPowerLevel: Long = 0L,
isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.USER,
) = RoomMember(
userId = userId,
displayName = displayName,
@@ -88,6 +89,7 @@ fun aRoomMember(
powerLevel = powerLevel,
normalizedPowerLevel = normalizedPowerLevel,
isIgnored = isIgnored,
role = role,
)
fun aRoomMemberList() = persistentListOf(
@@ -103,8 +105,8 @@ fun aRoomMemberList() = persistentListOf(
aWalter(),
)
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice")
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob")
fun anAlice() = aRoomMember(UserId("@alice:server.org"), "Alice", role = RoomMember.Role.ADMIN)
fun aBob() = aRoomMember(UserId("@bob:server.org"), "Bob", role = RoomMember.Role.MODERATOR)
fun aVictor() = aRoomMember(UserId("@victor:server.org"), "Victor", membership = RoomMembershipState.INVITE)

View File

@@ -177,14 +177,28 @@ private fun RoomMemberListItem(
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val roleText = when (roomMember.role) {
RoomMember.Role.ADMIN -> stringResource(R.string.screen_room_member_list_role_administrator)
RoomMember.Role.MODERATOR -> stringResource(R.string.screen_room_member_list_role_moderator)
RoomMember.Role.USER -> null
}
MatrixUserRow(
modifier = modifier.clickable(onClick = onClick),
matrixUser = MatrixUser(
userId = roomMember.userId,
displayName = roomMember.displayName,
avatarUrl = roomMember.avatarUrl
avatarUrl = roomMember.avatarUrl,
),
avatarSize = AvatarSize.UserListItem,
trailingContent = roleText?.let {
@Composable {
Text(
text = it,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
)
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.roomdetails.members
import io.element.android.features.roomdetails.impl.members.PowerLevelRoomMemberComparator
import io.element.android.features.roomdetails.impl.members.aRoomMember
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_ID_5
import org.junit.Test
class PowerLevelRoomMemberComparatorTest {
@Test
fun `order is Admin, then Moderator, then User`() {
val memberList = listOf(
aRoomMember(userId = UserId("@admin:example.com"), powerLevel = 100),
aRoomMember(userId = UserId("@moderator:example.com"), powerLevel = 50),
aRoomMember(userId = UserId("@user:example.com"), powerLevel = 0),
).shuffled()
val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator())
assert(ordered[0].userId == UserId("@admin:example.com"))
assert(ordered[1].userId == UserId("@moderator:example.com"))
assert(ordered[2].userId == UserId("@user:example.com"))
}
@Test
fun `with the same power level, alphabetical ascending order for name is used`() {
val memberList = listOf(
aRoomMember(userId = A_USER_ID, displayName = "First - admin", powerLevel = 100),
aRoomMember(userId = A_USER_ID_2, displayName = "Second - admin", powerLevel = 100),
aRoomMember(userId = A_USER_ID_3, displayName = "Third - admin", powerLevel = 100),
aRoomMember(userId = A_USER_ID_4, displayName = "First - user", powerLevel = 0),
aRoomMember(userId = A_USER_ID_5, displayName = "Second - user", powerLevel = 0),
).shuffled()
val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator())
assert(ordered[0].userId == A_USER_ID)
assert(ordered[1].userId == A_USER_ID_2)
assert(ordered[2].userId == A_USER_ID_3)
assert(ordered[3].userId == A_USER_ID_4)
assert(ordered[4].userId == A_USER_ID_5)
}
@Test
fun `when no names are provided, alphabetical order uses user id`() {
val memberList = listOf(
aRoomMember(userId = A_USER_ID, displayName = "Z - LAST!", powerLevel = 100),
aRoomMember(userId = A_USER_ID_2, powerLevel = 100),
aRoomMember(userId = A_USER_ID_3, powerLevel = 100),
).shuffled()
val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator())
assert(ordered[0].userId == A_USER_ID_2)
assert(ordered[1].userId == A_USER_ID_3)
assert(ordered[2].userId == A_USER_ID)
}
@Test
fun `unicode characters are simplified and compared, order ignores case`() {
val memberList = listOf(
aRoomMember(userId = A_USER_ID, displayName = "First", powerLevel = 100),
aRoomMember(userId = A_USER_ID_2, displayName = "Șecond", powerLevel = 100),
aRoomMember(userId = A_USER_ID_3, displayName = "third", powerLevel = 100),
).shuffled()
val ordered = memberList.sortedWith(PowerLevelRoomMemberComparator())
assert(ordered[0].userId == A_USER_ID)
assert(ordered[1].userId == A_USER_ID_2)
assert(ordered[2].userId == A_USER_ID_3)
}
}

View File

@@ -27,7 +27,17 @@ data class RoomMember(
val powerLevel: Long,
val normalizedPowerLevel: Long,
val isIgnored: Boolean,
val role: Role,
) {
/**
* Role of the RoomMember, based on its [powerLevel].
*/
enum class Role {
ADMIN,
MODERATOR,
USER
}
/**
* Disambiguated display name for the RoomMember.
* If the display name is null, the user ID is returned.
@@ -49,6 +59,10 @@ enum class RoomMembershipState {
LEAVE
}
/**
* Returns the best name value to display for the RoomMember.
* If the [RoomMember.displayName] is present and not empty it'll be used, otherwise the [RoomMember.userId] will be used.
*/
fun RoomMember.getBestName(): String {
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value
}

View File

@@ -24,5 +24,5 @@ import kotlinx.parcelize.Parcelize
data class MatrixUser(
val userId: UserId,
val displayName: String? = null,
val avatarUrl: String? = null
val avatarUrl: String? = null,
) : Parcelable

View File

@@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.room.member
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import uniffi.matrix_sdk.RoomMemberRole
import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
@@ -33,9 +34,17 @@ object RoomMemberMapper {
it.powerLevel(),
it.normalizedPowerLevel(),
it.isIgnored(),
mapRole(it.suggestedRoleForPowerLevel())
)
}
fun mapRole(role: RoomMemberRole): RoomMember.Role =
when (role) {
RoomMemberRole.ADMINISTRATOR -> RoomMember.Role.ADMIN
RoomMemberRole.MODERATOR -> RoomMember.Role.MODERATOR
RoomMemberRole.USER -> RoomMember.Role.USER
}
fun mapMembership(membershipState: RustMembershipState): RoomMembershipState =
when (membershipState) {
RustMembershipState.BAN -> RoomMembershipState.BAN

View File

@@ -34,6 +34,7 @@ import org.matrix.rustcomponents.sdk.NoPointer
import org.matrix.rustcomponents.sdk.Room
import org.matrix.rustcomponents.sdk.RoomMember
import org.matrix.rustcomponents.sdk.RoomMembersIterator
import uniffi.matrix_sdk.RoomMemberRole
class RoomMemberListFetcherTest {
@Test
@@ -268,6 +269,7 @@ class FakeRustRoomMember(
private val membership: MembershipState = MembershipState.JOIN,
private val isNameAmbiguous: Boolean = false,
private val powerLevel: Long = 0L,
private val role: RoomMemberRole = RoomMemberRole.USER,
) : RoomMember(NoPointer) {
override fun userId(): String {
return userId.value
@@ -300,4 +302,8 @@ class FakeRustRoomMember(
override fun isIgnored(): Boolean {
return false
}
override fun suggestedRoleForPowerLevel(): RoomMemberRole {
return role
}
}

View File

@@ -29,6 +29,7 @@ fun aRoomMember(
powerLevel: Long = 0L,
normalizedPowerLevel: Long = 0L,
isIgnored: Boolean = false,
role: RoomMember.Role = RoomMember.Role.USER,
) = RoomMember(
userId = userId,
displayName = displayName,
@@ -38,4 +39,5 @@ fun aRoomMember(
powerLevel = powerLevel,
normalizedPowerLevel = normalizedPowerLevel,
isIgnored = isIgnored,
role = role,
)

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2024 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.room
import io.element.android.libraries.matrix.api.room.RoomMember
/**
* Returns the name value to use when sorting room members.
*
* If the display name is not null and not empty, it is returned.
* Otherwise, the user ID is returned without the initial "@".
*/
fun RoomMember.sortingName(): String {
return displayName?.takeIf { it.isNotEmpty() } ?: userId.value.drop(1)
}