Merge pull request #5758 from element-hq/feature/bma/fixNPE

Fix null pointer exception on room notification settings.
This commit is contained in:
Benoit Marty 2025-11-19 11:05:05 +01:00 committed by GitHub
commit bbf6529c1e
13 changed files with 149 additions and 108 deletions

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
@ -49,6 +50,10 @@ class EditDefaultNotificationSettingPresenter(
fun create(oneToOne: Boolean): EditDefaultNotificationSettingPresenter fun create(oneToOne: Boolean): EditDefaultNotificationSettingPresenter
} }
private val collator = Collator.getInstance().apply {
decomposition = Collator.CANONICAL_DECOMPOSITION
}
@Composable @Composable
override fun present(): EditDefaultNotificationSettingState { override fun present(): EditDefaultNotificationSettingState {
var displayMentionsOnlyDisclaimer by remember { mutableStateOf(false) } var displayMentionsOnlyDisclaimer by remember { mutableStateOf(false) }
@ -121,10 +126,10 @@ class EditDefaultNotificationSettingPresenter(
summaries: List<RoomSummary>, summaries: List<RoomSummary>,
roomsWithUserDefinedMode: MutableState<List<EditNotificationSettingRoomInfo>> roomsWithUserDefinedMode: MutableState<List<EditNotificationSettingRoomInfo>>
) { ) {
val roomWithUserDefinedRules: Set<String> = notificationSettingsService.getRoomsWithUserDefinedRules().getOrDefault(emptyList()).toSet() val roomWithUserDefinedRules: Set<RoomId> = notificationSettingsService.getRoomsWithUserDefinedRules().getOrDefault(emptyList()).toSet()
roomsWithUserDefinedMode.value = summaries roomsWithUserDefinedMode.value = summaries
.filter { roomSummary -> .filter { roomSummary ->
roomWithUserDefinedRules.contains(roomSummary.roomId.value) && roomSummary.isOneToOne == isOneToOne roomWithUserDefinedRules.contains(roomSummary.roomId) && roomSummary.isOneToOne == isOneToOne
} }
.map { roomSummary -> .map { roomSummary ->
EditNotificationSettingRoomInfo( EditNotificationSettingRoomInfo(
@ -138,7 +143,12 @@ class EditDefaultNotificationSettingPresenter(
) )
} }
// locale sensitive sorting // locale sensitive sorting
.sortedWith(compareBy(Collator.getInstance()) { roomSummary -> roomSummary.name }) .sortedWith(
compareBy(collator) { roomSummary ->
// Collator does not handle null values, so we provide a fallback
roomSummary.name ?: roomSummary.roomId.value
}
)
} }
private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode, action: MutableState<AsyncAction<Unit>>) = launch { private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode, action: MutableState<AsyncAction<Unit>>) = launch {

View file

@ -8,33 +8,34 @@
package io.element.android.features.preferences.impl.notifications package io.element.android.features.preferences.impl.notifications
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingPresenter import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingPresenter
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingStateEvents import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingStateEvents
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.room.aRoomSummary
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class EditDefaultNotificationSettingsPresenterTest { class EditDefaultNotificationSettingsPresenterTest {
@Test @Test
fun `present - ensures initial state is correct`() = runTest { fun `present - ensures initial state is correct`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService() val notificationSettingsService = FakeNotificationSettingsService(
getRoomsWithUserDefinedRulesResult = { Result.success(emptyList()) },
)
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService) val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.mode).isNull() assertThat(initialState.mode).isNull()
assertThat(initialState.isOneToOne).isFalse() assertThat(initialState.isOneToOne).isFalse()
assertThat(initialState.roomsWithUserDefinedMode).isEmpty()
val loadedState = consumeItemsUntilPredicate { val loadedState = consumeItemsUntilPredicate {
it.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY it.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
@ -49,13 +50,12 @@ class EditDefaultNotificationSettingsPresenterTest {
fun `present - ensure list of rooms with user defined mode`() = runTest { fun `present - ensure list of rooms with user defined mode`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService( val notificationSettingsService = FakeNotificationSettingsService(
initialRoomMode = RoomNotificationMode.ALL_MESSAGES, initialRoomMode = RoomNotificationMode.ALL_MESSAGES,
initialRoomModeIsDefault = false initialRoomModeIsDefault = false,
getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID)) },
) )
val roomListService = FakeRoomListService() val roomListService = FakeRoomListService()
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService) val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = RoomNotificationMode.ALL_MESSAGES))) roomListService.postAllRooms(listOf(aRoomSummary(userDefinedNotificationMode = RoomNotificationMode.ALL_MESSAGES)))
val loadedState = consumeItemsUntilPredicate { state -> val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.ALL_MESSAGES } state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.ALL_MESSAGES }
@ -64,12 +64,78 @@ class EditDefaultNotificationSettingsPresenterTest {
} }
} }
@Test
fun `present - ensure list of rooms is sorted`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService(
initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
initialRoomModeIsDefault = false,
getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) },
)
val roomListService = FakeRoomListService()
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
presenter.test {
roomListService.postAllRooms(
listOf(
aRoomSummary(
roomId = A_ROOM_ID,
name = "Z",
userDefinedNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
),
aRoomSummary(
roomId = A_ROOM_ID_2,
name = "A",
userDefinedNotificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
),
),
)
val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY }
}.last()
assertThat(loadedState.roomsWithUserDefinedMode[0].name).isEqualTo("A")
assertThat(loadedState.roomsWithUserDefinedMode[1].name).isEqualTo("Z")
}
}
@Test
fun `present - ensure list of rooms is sorted, with name null`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService(
initialRoomMode = RoomNotificationMode.MUTE,
initialRoomModeIsDefault = false,
getRoomsWithUserDefinedRulesResult = { Result.success(listOf(A_ROOM_ID, A_ROOM_ID_2)) },
)
val roomListService = FakeRoomListService()
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService, roomListService)
presenter.test {
roomListService.postAllRooms(
listOf(
aRoomSummary(
roomId = A_ROOM_ID,
name = "Z",
userDefinedNotificationMode = RoomNotificationMode.MUTE,
),
aRoomSummary(
roomId = A_ROOM_ID_2,
name = null,
userDefinedNotificationMode = RoomNotificationMode.MUTE,
),
),
)
val loadedState = consumeItemsUntilPredicate { state ->
state.roomsWithUserDefinedMode.any { it.notificationMode == RoomNotificationMode.MUTE }
}.last()
assertThat(loadedState.roomsWithUserDefinedMode[0].name).isNull()
assertThat(loadedState.roomsWithUserDefinedMode[1].name).isEqualTo("Z")
}
}
@Test @Test
fun `present - edit default notification setting`() = runTest { fun `present - edit default notification setting`() = runTest {
val presenter = createEditDefaultNotificationSettingPresenter() val presenter = createEditDefaultNotificationSettingPresenter(
moleculeFlow(RecompositionMode.Immediate) { notificationSettingsService = FakeNotificationSettingsService(
presenter.present() getRoomsWithUserDefinedRulesResult = { Result.success(emptyList()) },
}.test { ),
)
presenter.test {
awaitItem().eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(RoomNotificationMode.ALL_MESSAGES)) awaitItem().eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(RoomNotificationMode.ALL_MESSAGES))
val loadedState = consumeItemsUntilPredicate { val loadedState = consumeItemsUntilPredicate {
it.mode == RoomNotificationMode.ALL_MESSAGES it.mode == RoomNotificationMode.ALL_MESSAGES
@ -80,12 +146,12 @@ class EditDefaultNotificationSettingsPresenterTest {
@Test @Test
fun `present - edit default notification setting failed`() = runTest { fun `present - edit default notification setting failed`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService() val notificationSettingsService = FakeNotificationSettingsService(
getRoomsWithUserDefinedRulesResult = { Result.success(emptyList()) },
)
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService) val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService)
notificationSettingsService.givenSetDefaultNotificationModeError(AN_EXCEPTION) notificationSettingsService.givenSetDefaultNotificationModeError(AN_EXCEPTION)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
awaitItem().eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(RoomNotificationMode.ALL_MESSAGES)) awaitItem().eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(RoomNotificationMode.ALL_MESSAGES))
val errorState = consumeItemsUntilPredicate { val errorState = consumeItemsUntilPredicate {
it.changeNotificationSettingAction.isFailure() it.changeNotificationSettingAction.isFailure()
@ -101,13 +167,13 @@ class EditDefaultNotificationSettingsPresenterTest {
@Test @Test
fun `present - display mentions only warning if homeserver does not support it`() = runTest { fun `present - display mentions only warning if homeserver does not support it`() = runTest {
val notificationSettingsService = FakeNotificationSettingsService().apply { val notificationSettingsService = FakeNotificationSettingsService(
getRoomsWithUserDefinedRulesResult = { Result.success(emptyList()) },
).apply {
givenCanHomeServerPushEncryptedEventsToDeviceResult(Result.success(false)) givenCanHomeServerPushEncryptedEventsToDeviceResult(Result.success(false))
} }
val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService) val presenter = createEditDefaultNotificationSettingPresenter(notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
presenter.present()
}.test {
assertThat(awaitLastSequentialItem().displayMentionsOnlyDisclaimer).isTrue() assertThat(awaitLastSequentialItem().displayMentionsOnlyDisclaimer).isTrue()
} }
} }

View file

@ -61,6 +61,8 @@ class ChangeRolesPresenter(
fun create(role: RoomMember.Role): ChangeRolesPresenter fun create(role: RoomMember.Role): ChangeRolesPresenter
} }
private val powerLevelRoomMemberComparator = PowerLevelRoomMemberComparator()
@Composable @Composable
override fun present(): ChangeRolesState { override fun present(): ChangeRolesState {
val dataSource = remember { RoomMemberListDataSource(room, dispatchers) } val dataSource = remember { RoomMemberListDataSource(room, dispatchers) }
@ -176,17 +178,7 @@ class ChangeRolesPresenter(
} }
private fun List<RoomMember>.groupedByRole(): MembersByRole { private fun List<RoomMember>.groupedByRole(): MembersByRole {
val groupedMembers = MembersByRole(this) return MembersByRole(this, powerLevelRoomMemberComparator)
return MembersByRole(
owners = groupedMembers.owners.sorted(),
admins = groupedMembers.admins.sorted(),
moderators = groupedMembers.moderators.sorted(),
members = groupedMembers.members.sorted(),
)
}
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> {
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList()
} }
private fun CoroutineScope.save( private fun CoroutineScope.save(

View file

@ -13,8 +13,8 @@ import io.element.android.libraries.designsystem.theme.components.SearchBarResul
import io.element.android.libraries.matrix.api.core.UserId 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.RoomMember
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
data class ChangeRolesState( data class ChangeRolesState(
@ -30,21 +30,24 @@ data class ChangeRolesState(
) )
data class MembersByRole( data class MembersByRole(
val owners: ImmutableList<RoomMember>, val owners: ImmutableList<RoomMember> = persistentListOf(),
val admins: ImmutableList<RoomMember>, val admins: ImmutableList<RoomMember> = persistentListOf(),
val moderators: ImmutableList<RoomMember>, val moderators: ImmutableList<RoomMember> = persistentListOf(),
val members: ImmutableList<RoomMember>, val members: ImmutableList<RoomMember> = persistentListOf(),
) { ) {
constructor(members: List<RoomMember>) : this( constructor(members: List<RoomMember>, comparator: Comparator<RoomMember>) : this(
owners = members.filter { it.role is RoomMember.Role.Owner }.sorted(), owners = members.filterAndSort(comparator) { it.role is RoomMember.Role.Owner },
admins = members.filter { it.role == RoomMember.Role.Admin }.sorted(), admins = members.filterAndSort(comparator) { it.role == RoomMember.Role.Admin },
moderators = members.filter { it.role == RoomMember.Role.Moderator }.sorted(), moderators = members.filterAndSort(comparator) { it.role == RoomMember.Role.Moderator },
members = members.filter { it.role == RoomMember.Role.User }.sorted(), members = members.filterAndSort(comparator) { it.role == RoomMember.Role.User },
) )
fun isEmpty() = owners.isEmpty() && admins.isEmpty() && moderators.isEmpty() && members.isEmpty() fun isEmpty() = owners.isEmpty() && admins.isEmpty() && moderators.isEmpty() && members.isEmpty()
} }
private fun Iterable<RoomMember>.sorted(): ImmutableList<RoomMember> { private fun Iterable<RoomMember>.filterAndSort(
return sortedWith(PowerLevelRoomMemberComparator()).toImmutableList() comparator: Comparator<RoomMember>,
predicate: (RoomMember) -> Boolean,
): ImmutableList<RoomMember> {
return filter(predicate).sortedWith(comparator).toImmutableList()
} }

View file

@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import io.element.android.libraries.previewutils.room.aRoomMember import io.element.android.libraries.previewutils.room.aRoomMember
import io.element.android.libraries.previewutils.room.aRoomMemberList import io.element.android.libraries.previewutils.room.aRoomMemberList
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -36,7 +37,12 @@ class ChangeRolesStateProvider : PreviewParameterProvider<ChangeRolesState> {
aChangeRolesStateWithSelectedUsers().copy( aChangeRolesStateWithSelectedUsers().copy(
query = "Alice", query = "Alice",
isSearchActive = true, isSearchActive = true,
searchResults = SearchBarResultState.Results(MembersByRole(aRoomMemberList().take(1).toImmutableList())), searchResults = SearchBarResultState.Results(
MembersByRole(
members = aRoomMemberList().take(1),
comparator = PowerLevelRoomMemberComparator(),
)
),
selectedUsers = aMatrixUserList().take(1).toImmutableList(), selectedUsers = aMatrixUserList().take(1).toImmutableList(),
), ),
aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingCancellation), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingCancellation),
@ -87,7 +93,8 @@ internal fun aChangeRolesStateWithSelectedUsers() = aChangeRolesState(
} else { } else {
roomMember roomMember
} }
} },
comparator = PowerLevelRoomMemberComparator(),
) )
), ),
hasPendingChanges = true, hasPendingChanges = true,
@ -126,7 +133,8 @@ internal fun aChangeRolesStateWithOwners(
displayName = "David", displayName = "David",
role = RoomMember.Role.User, role = RoomMember.Role.User,
), ),
) ),
comparator = PowerLevelRoomMemberComparator(),
), ),
), ),
canRemoveMember = { userId -> canRemoveMember = { userId ->

View file

@ -145,7 +145,7 @@ fun ChangeRolesView(
SearchResultsList( SearchResultsList(
currentRole = state.role, currentRole = state.role,
lazyListState = lazyListState, lazyListState = lazyListState,
searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: MembersByRole(emptyList()), searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: MembersByRole(),
selectedUsers = state.selectedUsers, selectedUsers = state.selectedUsers,
canRemoveMember = state.canChangeMemberRole, canRemoveMember = state.canChangeMemberRole,
onToggleSelection = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) }, onToggleSelection = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) },

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID_5
import io.element.android.libraries.matrix.test.A_USER_ID_6 import io.element.android.libraries.matrix.test.A_USER_ID_6
import io.element.android.libraries.matrix.test.A_USER_ID_7 import io.element.android.libraries.matrix.test.A_USER_ID_7
import io.element.android.libraries.matrix.test.room.aRoomMember import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import org.junit.Test import org.junit.Test
@ -33,7 +34,7 @@ class MembersByRoleTest {
aRoomMember(A_USER_ID_6, displayName = "Justin", role = RoomMember.Role.Owner(isCreator = true)), aRoomMember(A_USER_ID_6, displayName = "Justin", role = RoomMember.Role.Owner(isCreator = true)),
aRoomMember(A_USER_ID_7, displayName = "Mallory", role = RoomMember.Role.Owner(isCreator = false)), aRoomMember(A_USER_ID_7, displayName = "Mallory", role = RoomMember.Role.Owner(isCreator = false)),
) )
val membersByRole = MembersByRole(members = members) val membersByRole = MembersByRole(members = members, comparator = PowerLevelRoomMemberComparator())
assertThat(membersByRole.owners).containsExactly( assertThat(membersByRole.owners).containsExactly(
aRoomMember(A_USER_ID_6, displayName = "Justin", role = RoomMember.Role.Owner(isCreator = true)), aRoomMember(A_USER_ID_6, displayName = "Justin", role = RoomMember.Role.Owner(isCreator = true)),
aRoomMember(A_USER_ID_7, displayName = "Mallory", role = RoomMember.Role.Owner(isCreator = false)), aRoomMember(A_USER_ID_7, displayName = "Mallory", role = RoomMember.Role.Owner(isCreator = false)),
@ -52,37 +53,25 @@ class MembersByRoleTest {
@Test @Test
fun `isEmpty - only returns true with no members of any role`() { fun `isEmpty - only returns true with no members of any role`() {
val emptyMembersByRole = MembersByRole(emptyList()) val emptyMembersByRole = MembersByRole()
assertThat(emptyMembersByRole.isEmpty()).isTrue() assertThat(emptyMembersByRole.isEmpty()).isTrue()
val membersByRoleWithOwners = MembersByRole( val membersByRoleWithOwners = MembersByRole(
owners = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Admin)), owners = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Admin)),
admins = persistentListOf(),
moderators = persistentListOf(),
members = persistentListOf(),
) )
assertThat(membersByRoleWithOwners.isEmpty()).isFalse() assertThat(membersByRoleWithOwners.isEmpty()).isFalse()
val membersByRoleWithAdmins = MembersByRole( val membersByRoleWithAdmins = MembersByRole(
owners = persistentListOf(),
admins = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Admin)), admins = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Admin)),
moderators = persistentListOf(),
members = persistentListOf(),
) )
assertThat(membersByRoleWithAdmins.isEmpty()).isFalse() assertThat(membersByRoleWithAdmins.isEmpty()).isFalse()
val membersByRoleWithModerators = MembersByRole( val membersByRoleWithModerators = MembersByRole(
owners = persistentListOf(),
admins = persistentListOf(),
moderators = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Moderator)), moderators = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.Moderator)),
members = persistentListOf(),
) )
assertThat(membersByRoleWithModerators.isEmpty()).isFalse() assertThat(membersByRoleWithModerators.isEmpty()).isFalse()
val membersByRoleWithMembers = MembersByRole( val membersByRoleWithMembers = MembersByRole(
owners = persistentListOf(),
admins = persistentListOf(),
moderators = persistentListOf(),
members = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.User)), members = persistentListOf(aRoomMember(A_USER_ID, role = RoomMember.Role.User)),
) )
assertThat(membersByRoleWithMembers.isEmpty()).isFalse() assertThat(membersByRoleWithMembers.isEmpty()).isFalse()

View file

@ -1,30 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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

@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.room.toMatrixUser import io.element.android.libraries.matrix.api.room.toMatrixUser
import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator
import io.element.android.libraries.matrix.ui.room.canInviteAsState import io.element.android.libraries.matrix.ui.room.canInviteAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableMap
@ -52,7 +53,8 @@ class RoomMemberListPresenter(
private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>, private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
) : Presenter<RoomMemberListState> { ) : Presenter<RoomMemberListState> {
var roomMembers: AsyncData<RoomMembers> by mutableStateOf(AsyncData.Loading()) private var roomMembers: AsyncData<RoomMembers> by mutableStateOf(AsyncData.Loading())
private val powerLevelRoomMemberComparator = PowerLevelRoomMemberComparator()
@Composable @Composable
override fun present(): RoomMemberListState { override fun present(): RoomMemberListState {
@ -103,7 +105,7 @@ class RoomMemberListPresenter(
.map { it.withIdentityState(roomMemberIdentityStates) } .map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(), .toImmutableList(),
joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList()) joined = members.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator()) .sortedWith(powerLevelRoomMemberComparator)
.map { it.withIdentityState(roomMemberIdentityStates) } .map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(), .toImmutableList(),
banned = members.getOrDefault(RoomMembershipState.BAN, emptyList()) banned = members.getOrDefault(RoomMembershipState.BAN, emptyList())
@ -133,7 +135,7 @@ class RoomMemberListPresenter(
.map { it.withIdentityState(roomMemberIdentityStates) } .map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(), .toImmutableList(),
joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList()) joined = results.getOrDefault(RoomMembershipState.JOIN, emptyList())
.sortedWith(PowerLevelRoomMemberComparator()) .sortedWith(powerLevelRoomMemberComparator)
.map { it.withIdentityState(roomMemberIdentityStates) } .map { it.withIdentityState(roomMemberIdentityStates) }
.toImmutableList(), .toImmutableList(),
banned = results.getOrDefault(RoomMembershipState.BAN, emptyList()) banned = results.getOrDefault(RoomMembershipState.BAN, emptyList())

View file

@ -32,7 +32,7 @@ interface NotificationSettingsService {
suspend fun setCallEnabled(enabled: Boolean): Result<Unit> suspend fun setCallEnabled(enabled: Boolean): Result<Unit>
suspend fun isInviteForMeEnabled(): Result<Boolean> suspend fun isInviteForMeEnabled(): Result<Boolean>
suspend fun setInviteForMeEnabled(enabled: Boolean): Result<Unit> suspend fun setInviteForMeEnabled(enabled: Boolean): Result<Unit>
suspend fun getRoomsWithUserDefinedRules(): Result<List<String>> suspend fun getRoomsWithUserDefinedRules(): Result<List<RoomId>>
suspend fun canHomeServerPushEncryptedEventsToDevice(): Result<Boolean> suspend fun canHomeServerPushEncryptedEventsToDevice(): Result<Boolean>
suspend fun getRawPushRules(): Result<String?> suspend fun getRawPushRules(): Result<String?>
} }

View file

@ -131,9 +131,9 @@ class RustNotificationSettingsService(
} }
} }
override suspend fun getRoomsWithUserDefinedRules(): Result<List<String>> = override suspend fun getRoomsWithUserDefinedRules(): Result<List<RoomId>> =
runCatchingExceptions { runCatchingExceptions {
notificationSettings.await().getRoomsWithUserDefinedRules(enabled = true) notificationSettings.await().getRoomsWithUserDefinedRules(enabled = true).map(::RoomId)
} }
override suspend fun canHomeServerPushEncryptedEventsToDevice(): Result<Boolean> = override suspend fun canHomeServerPushEncryptedEventsToDevice(): Result<Boolean> =

View file

@ -12,7 +12,6 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.RoomNotificationSettings import io.element.android.libraries.matrix.api.room.RoomNotificationSettings
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE import io.element.android.libraries.matrix.test.A_ROOM_NOTIFICATION_MODE
import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaError
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -26,6 +25,7 @@ class FakeNotificationSettingsService(
initialOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, initialOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
initialEncryptedOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, initialEncryptedOneToOneDefaultMode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES,
private val getRawPushRulesResult: () -> Result<String> = { lambdaError() }, private val getRawPushRulesResult: () -> Result<String> = { lambdaError() },
private val getRoomsWithUserDefinedRulesResult: () -> Result<List<RoomId>> = { lambdaError() },
) : NotificationSettingsService { ) : NotificationSettingsService {
private val notificationSettingsStateFlow = MutableStateFlow(Unit) private val notificationSettingsStateFlow = MutableStateFlow(Unit)
private var defaultGroupRoomNotificationMode: RoomNotificationMode = initialGroupDefaultMode private var defaultGroupRoomNotificationMode: RoomNotificationMode = initialGroupDefaultMode
@ -154,8 +154,8 @@ class FakeNotificationSettingsService(
return Result.success(Unit) return Result.success(Unit)
} }
override suspend fun getRoomsWithUserDefinedRules(): Result<List<String>> { override suspend fun getRoomsWithUserDefinedRules(): Result<List<RoomId>> {
return Result.success(if (roomNotificationModeIsDefault) listOf() else listOf(A_ROOM_ID.value)) return getRoomsWithUserDefinedRulesResult()
} }
override suspend fun canHomeServerPushEncryptedEventsToDevice(): Result<Boolean> { override suspend fun canHomeServerPushEncryptedEventsToDevice(): Result<Boolean> {

View file

@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.roomdetails.impl.members package io.element.android.libraries.matrix.ui.room
import io.element.android.libraries.matrix.api.core.UserId 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
@ -14,9 +14,10 @@ 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_3
import io.element.android.libraries.matrix.test.A_USER_ID_4 import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_ID_5 import io.element.android.libraries.matrix.test.A_USER_ID_5
import io.element.android.libraries.matrix.test.room.aRoomMember
import org.junit.Test import org.junit.Test
class PowerLevelBaseRoomMemberComparatorTest { class PowerLevelRoomMemberComparatorTest {
@Test @Test
fun `order is Admin, then Moderator, then User`() { fun `order is Admin, then Moderator, then User`() {
val memberList = listOf( val memberList = listOf(