change(members): make sure state is not lost when navigating

This commit is contained in:
ganfra 2025-11-20 16:41:43 +01:00
parent 80d799c4db
commit dcaac703fc
6 changed files with 153 additions and 125 deletions

View file

@ -11,6 +11,7 @@ package io.element.android.features.roomdetails.impl.members
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
sealed interface RoomMemberListEvents { sealed interface RoomMemberListEvents {
data class ChangeSelectedSection(val section: SelectedSection): RoomMemberListEvents
data class UpdateSearchQuery(val query: String) : RoomMemberListEvents data class UpdateSearchQuery(val query: String) : RoomMemberListEvents
data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents data class OnSearchActiveChanged(val active: Boolean) : RoomMemberListEvents
data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvents data class RoomMemberSelected(val roomMember: RoomMember) : RoomMemberListEvents

View file

@ -9,6 +9,8 @@
package io.element.android.features.roomdetails.impl.members package io.element.android.features.roomdetails.impl.members
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
@ -21,6 +23,7 @@ import io.element.android.annotations.ContributesNode
import io.element.android.features.roommembermoderation.api.ModerationAction import io.element.android.features.roommembermoderation.api.ModerationAction
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents
import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer import io.element.android.features.roommembermoderation.api.RoomMemberModerationRenderer
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
@ -41,6 +44,7 @@ class RoomMemberListNode(
} }
private val callback: Callback = callback() private val callback: Callback = callback()
private val stateFlow = launchMolecule { presenter.present() }
init { init {
lifecycle.subscribe( lifecycle.subscribe(
@ -64,7 +68,7 @@ class RoomMemberListNode(
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state by stateFlow.collectAsState()
RoomMemberListView( RoomMemberListView(
state = state, state = state,
modifier = modifier, modifier = modifier,

View file

@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl.members
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -18,7 +19,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents.ShowActionsForUser
import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.features.roommembermoderation.api.RoomMemberModerationState
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -53,7 +54,7 @@ class RoomMemberListPresenter(
private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>, private val roomMembersModerationPresenter: Presenter<RoomMemberModerationState>,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
) : Presenter<RoomMemberListState> { ) : Presenter<RoomMemberListState> {
private var roomMembers: AsyncData<RoomMembers> by mutableStateOf(AsyncData.Loading())
private val powerLevelRoomMemberComparator = PowerLevelRoomMemberComparator() private val powerLevelRoomMemberComparator = PowerLevelRoomMemberComparator()
@Composable @Composable
@ -77,6 +78,9 @@ class RoomMemberListPresenter(
.launchIn(this) .launchIn(this)
} }
var roomMembers: AsyncData<RoomMembers> by remember { mutableStateOf(AsyncData.Loading())}
var selectedSection by remember { mutableStateOf(SelectedSection.MEMBERS)}
// Update the room members when the screen is loaded // Update the room members when the screen is loaded
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
room.updateMembers() room.updateMembers()
@ -160,7 +164,14 @@ class RoomMemberListPresenter(
is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active is RoomMemberListEvents.OnSearchActiveChanged -> isSearchActive = event.active
is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query is RoomMemberListEvents.UpdateSearchQuery -> searchQuery = event.query
is RoomMemberListEvents.RoomMemberSelected -> is RoomMemberListEvents.RoomMemberSelected ->
roomModerationState.eventSink(RoomMemberModerationEvents.ShowActionsForUser(event.roomMember.toMatrixUser())) roomModerationState.eventSink(ShowActionsForUser(event.roomMember.toMatrixUser()))
is RoomMemberListEvents.ChangeSelectedSection -> selectedSection = event.section
}
}
if (!roomModerationState.canBan && selectedSection == SelectedSection.BANNED) {
SideEffect {
selectedSection = SelectedSection.MEMBERS
} }
} }
@ -171,6 +182,7 @@ class RoomMemberListPresenter(
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
canInvite = canInvite, canInvite = canInvite,
moderationState = roomModerationState, moderationState = roomModerationState,
selectedSection = selectedSection,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -21,10 +21,16 @@ data class RoomMemberListState(
val searchResults: SearchBarResultState<AsyncData<RoomMembers>>, val searchResults: SearchBarResultState<AsyncData<RoomMembers>>,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val canInvite: Boolean, val canInvite: Boolean,
val selectedSection: SelectedSection,
val moderationState: RoomMemberModerationState, val moderationState: RoomMemberModerationState,
val eventSink: (RoomMemberListEvents) -> Unit, val eventSink: (RoomMemberListEvents) -> Unit,
) )
enum class SelectedSection {
MEMBERS,
BANNED
}
data class RoomMembers( data class RoomMembers(
val invited: ImmutableList<RoomMemberWithIdentityState>, val invited: ImmutableList<RoomMemberWithIdentityState>,
val joined: ImmutableList<RoomMemberWithIdentityState>, val joined: ImmutableList<RoomMemberWithIdentityState>,

View file

@ -21,107 +21,131 @@ import kotlinx.collections.immutable.persistentListOf
internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> { internal class RoomMemberListStateProvider : PreviewParameterProvider<RoomMemberListState> {
override val values: Sequence<RoomMemberListState> override val values: Sequence<RoomMemberListState>
get() = sequenceOf( get() = roomMemberListStates() + bannedRoomMemberListStates()
aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor().withIdentity(), aWalter().withIdentity()),
joined = persistentListOf(anAlice().withIdentity(), aBob().withIdentity(), aWalter().withIdentity()),
banned = persistentListOf(),
)
)
),
aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor().withIdentity(), aWalter().withIdentity()),
joined = persistentListOf(
anAlice().withIdentity(identityState = IdentityState.Verified),
aBob().withIdentity(identityState = IdentityState.PinViolation),
aWalter().withIdentity(identityState = IdentityState.VerificationViolation)
),
banned = persistentListOf(),
)
),
moderationState = aRoomMemberModerationState(canBan = true)
),
aRoomMemberListState(roomMembers = AsyncData.Loading()),
aRoomMemberListState().copy(canInvite = true),
aRoomMemberListState().copy(isSearchActive = false),
aRoomMemberListState().copy(isSearchActive = true),
aRoomMemberListState().copy(isSearchActive = true, searchQuery = "someone"),
aRoomMemberListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
searchResults = SearchBarResultState.Results(
AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor().withIdentity()),
joined = persistentListOf(anAlice().withIdentity()),
banned = persistentListOf(),
)
)
),
),
aRoomMemberListState().copy(
isSearchActive = true,
searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResultsFound()
),
aRoomMemberListState(
roomMembers = AsyncData.Failure(Exception("Error details")),
),
)
} }
internal class RoomMemberListStateBannedProvider : PreviewParameterProvider<RoomMemberListState> { private fun roomMemberListStates(): Sequence<RoomMemberListState> = sequenceOf(
override val values: Sequence<RoomMemberListState> aRoomMemberListState(
get() = sequenceOf( roomMembers = AsyncData.Success(
aRoomMemberListState( RoomMembers(
roomMembers = AsyncData.Success( invited = persistentListOf(aVictor().withIdentity(), aWalter().withIdentity()),
RoomMembers( joined = persistentListOf(anAlice().withIdentity(), aBob().withIdentity(), aWalter().withIdentity()),
invited = persistentListOf(), banned = persistentListOf(),
joined = persistentListOf(),
banned = persistentListOf(
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(),
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(),
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(),
),
)
),
moderationState = aRoomMemberModerationState(),
), ),
aRoomMemberListState( ),
roomMembers = AsyncData.Loading( selectedSection = SelectedSection.MEMBERS,
RoomMembers( ),
invited = persistentListOf(), aRoomMemberListState(
joined = persistentListOf(), roomMembers = AsyncData.Success(
banned = persistentListOf( RoomMembers(
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(), invited = persistentListOf(aVictor().withIdentity(), aWalter().withIdentity()),
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(), joined = persistentListOf(
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(), anAlice().withIdentity(identityState = IdentityState.Verified),
), aBob().withIdentity(identityState = IdentityState.PinViolation),
) aWalter().withIdentity(identityState = IdentityState.VerificationViolation)
), ),
moderationState = aRoomMemberModerationState(), banned = persistentListOf(),
),
aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(),
joined = persistentListOf(),
banned = persistentListOf(),
)
),
moderationState = aRoomMemberModerationState(),
) )
) ),
} selectedSection = SelectedSection.MEMBERS,
moderationState = aRoomMemberModerationState(canBan = true)
),
aRoomMemberListState(
roomMembers = AsyncData.Loading(),
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState().copy(
canInvite = true,
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState().copy(
isSearchActive = false,
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState().copy(
isSearchActive = true,
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState().copy(
isSearchActive = true,
searchQuery = "someone",
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState().copy(
isSearchActive = true,
searchQuery = "@someone:matrix.org",
searchResults = SearchBarResultState.Results(
AsyncData.Success(
RoomMembers(
invited = persistentListOf(aVictor().withIdentity()),
joined = persistentListOf(anAlice().withIdentity()),
banned = persistentListOf(),
)
)
),
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState().copy(
isSearchActive = true,
searchQuery = "something-with-no-results",
searchResults = SearchBarResultState.NoResultsFound(),
selectedSection = SelectedSection.MEMBERS,
),
aRoomMemberListState(
roomMembers = AsyncData.Failure(Exception("Error details")),
selectedSection = SelectedSection.MEMBERS,
),
)
private fun bannedRoomMemberListStates(): Sequence<RoomMemberListState> = sequenceOf(
aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(),
joined = persistentListOf(),
banned = persistentListOf(
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(),
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(),
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(),
),
)
),
moderationState = aRoomMemberModerationState(),
selectedSection = SelectedSection.BANNED,
),
aRoomMemberListState(
roomMembers = AsyncData.Loading(
RoomMembers(
invited = persistentListOf(),
joined = persistentListOf(),
banned = persistentListOf(
aRoomMember(userId = UserId("@alice:example.com"), displayName = "Alice").withIdentity(),
aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob").withIdentity(),
aRoomMember(userId = UserId("@charlie:example.com"), displayName = "Charlie").withIdentity(),
),
)
),
moderationState = aRoomMemberModerationState(),
selectedSection = SelectedSection.BANNED,
),
aRoomMemberListState(
roomMembers = AsyncData.Success(
RoomMembers(
invited = persistentListOf(),
joined = persistentListOf(),
banned = persistentListOf(),
)
),
moderationState = aRoomMemberModerationState(),
selectedSection = SelectedSection.BANNED,
)
)
internal fun aRoomMemberListState( internal fun aRoomMemberListState(
roomMembers: AsyncData<RoomMembers> = AsyncData.Loading(), roomMembers: AsyncData<RoomMembers> = AsyncData.Loading(),
searchResults: SearchBarResultState<AsyncData<RoomMembers>> = SearchBarResultState.Initial(), searchResults: SearchBarResultState<AsyncData<RoomMembers>> = SearchBarResultState.Initial(),
moderationState: RoomMemberModerationState = aRoomMemberModerationState(), moderationState: RoomMemberModerationState = aRoomMemberModerationState(),
selectedSection: SelectedSection = SelectedSection.MEMBERS,
) = RoomMemberListState( ) = RoomMemberListState(
roomMembers = roomMembers, roomMembers = roomMembers,
searchQuery = "", searchQuery = "",
@ -129,6 +153,7 @@ internal fun aRoomMemberListState(
isSearchActive = false, isSearchActive = false,
canInvite = false, canInvite = false,
moderationState = moderationState, moderationState = moderationState,
selectedSection = SelectedSection.MEMBERS,
eventSink = {} eventSink = {}
) )

View file

@ -30,10 +30,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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
@ -48,6 +45,7 @@ import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
@ -68,17 +66,11 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
private enum class SelectedSection {
MEMBERS,
BANNED
}
@Composable @Composable
fun RoomMemberListView( fun RoomMemberListView(
state: RoomMemberListState, state: RoomMemberListState,
navigator: RoomMemberListNavigator, navigator: RoomMemberListNavigator,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialSelectedSectionIndex: Int = 0,
) { ) {
fun onSelectUser(roomMember: RoomMember) { fun onSelectUser(roomMember: RoomMember) {
state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember)) state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember))
@ -96,12 +88,6 @@ fun RoomMemberListView(
} }
} }
) { padding -> ) { padding ->
var selectedSection by remember { mutableStateOf(SelectedSection.entries[initialSelectedSectionIndex]) }
if (!state.moderationState.canBan && selectedSection == SelectedSection.BANNED) {
SideEffect {
selectedSection = SelectedSection.MEMBERS
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -117,7 +103,7 @@ fun RoomMemberListView(
onActiveChange = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) }, onActiveChange = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) },
onTextChange = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) }, onTextChange = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) },
onSelectUser = ::onSelectUser, onSelectUser = ::onSelectUser,
selectedSection = selectedSection, selectedSection = state.selectedSection,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@ -126,8 +112,8 @@ fun RoomMemberListView(
roomMembers = state.roomMembers, roomMembers = state.roomMembers,
showMembersCount = true, showMembersCount = true,
canDisplayBannedUsersControls = state.moderationState.canBan, canDisplayBannedUsersControls = state.moderationState.canBan,
selectedSection = selectedSection, selectedSection = state.selectedSection,
onSelectedSectionChange = { selectedSection = it }, onSelectedSectionChange = { state.eventSink(RoomMemberListEvents.ChangeSelectedSection(it)) },
onSelectUser = ::onSelectUser, onSelectUser = ::onSelectUser,
) )
} }
@ -380,9 +366,13 @@ private fun RoomMemberSearchBar(
selectedSection: SelectedSection, selectedSection: SelectedSection,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var queryFieldState by textFieldState(query)
SearchBar( SearchBar(
query = query, query = queryFieldState,
onQueryChange = onTextChange, onQueryChange = { newQuery ->
queryFieldState = newQuery
onTextChange(newQuery)
},
active = active, active = active,
onActiveChange = onActiveChange, onActiveChange = onActiveChange,
modifier = modifier, modifier = modifier,
@ -409,13 +399,3 @@ internal fun RoomMemberListViewPreview(@PreviewParameter(RoomMemberListStateProv
navigator = object : RoomMemberListNavigator {}, navigator = object : RoomMemberListNavigator {},
) )
} }
@PreviewsDayNight
@Composable
internal fun RoomMemberListViewBannedPreview(@PreviewParameter(RoomMemberListStateBannedProvider::class) state: RoomMemberListState) = ElementPreview {
RoomMemberListView(
initialSelectedSectionIndex = 1,
state = state,
navigator = object : RoomMemberListNavigator {},
)
}