Show progress dialog while we are sending invites in a room (#5342)

* Add `InvitePeopleState.sendInvitesAction`

Keep track of the progress on sending invites with a new state property.

* Keep `RoomInviteMembersView` open until invites are sent

* Sync strings from localazy

* extend `ProgressDialog` to support custom content

For my current design, a simple text element is insufficient. I extend
`ProgressDialog` to give more flexibility over the content of the dialog.

* Show progress dialog while invites are being sent

* Add new ProgressDialog previews to the naming exceptions list

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
Co-authored-by: Jorge Martín <jorgem@element.io>
This commit is contained in:
Richard van der Hoff 2025-09-12 11:35:37 +01:00 committed by GitHub
parent 06e7bc1604
commit 5f920eb100
22 changed files with 189 additions and 19 deletions

View file

@ -23,9 +23,11 @@ import dev.zacsweers.metro.Inject
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeoplePresenter
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.map
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.di.SessionScope
@ -73,6 +75,8 @@ class DefaultInvitePeoplePresenter(
var searchQuery by rememberSaveable { mutableStateOf("") }
var searchActive by rememberSaveable { mutableStateOf(false) }
val showSearchLoader = rememberSaveable { mutableStateOf(false) }
val sendInvitesAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val room by produceState(if (joinedRoom != null) AsyncData.Success(joinedRoom) else AsyncData.Loading()) {
if (joinedRoom == null) {
val result = matrixClient.getJoinedRoom(roomId)
@ -116,7 +120,7 @@ class DefaultInvitePeoplePresenter(
}
is InvitePeopleEvents.SendInvites -> {
room.dataOrNull()?.let {
sessionCoroutineScope.sendInvites(it, selectedUsers.value)
sessionCoroutineScope.sendInvites(it, selectedUsers.value, sendInvitesAction)
}
}
is InvitePeopleEvents.CloseSearch -> {
@ -128,12 +132,13 @@ class DefaultInvitePeoplePresenter(
return DefaultInvitePeopleState(
room = room.map { },
canInvite = selectedUsers.value.isNotEmpty(),
canInvite = selectedUsers.value.isNotEmpty() && !sendInvitesAction.value.isLoading(),
selectedUsers = selectedUsers.value,
searchQuery = searchQuery,
isSearchActive = searchActive,
searchResults = searchResults.value,
showSearchLoader = showSearchLoader.value,
sendInvitesAction = sendInvitesAction.value,
eventSink = ::handleEvents,
)
}
@ -141,16 +146,21 @@ class DefaultInvitePeoplePresenter(
private fun CoroutineScope.sendInvites(
room: JoinedRoom,
selectedUsers: List<MatrixUser>,
sendInvitesAction: MutableState<AsyncAction<Unit>>,
) = launch {
val anyInviteFailed = selectedUsers
.map { room.inviteUserById(it.userId) }
.any { it.isFailure }
sendInvitesAction.runUpdatingState {
val anyInviteFailed = selectedUsers
.map { room.inviteUserById(it.userId) }
.any { it.isFailure }
if (anyInviteFailed) {
appErrorStateService.showError(
titleRes = CommonStrings.common_unable_to_invite_title,
bodyRes = CommonStrings.common_unable_to_invite_message,
)
if (anyInviteFailed) {
appErrorStateService.showError(
titleRes = CommonStrings.common_unable_to_invite_title,
bodyRes = CommonStrings.common_unable_to_invite_message,
)
}
Result.success(Unit)
}
}

View file

@ -9,6 +9,7 @@ package io.element.android.features.invitepeople.impl
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -22,5 +23,6 @@ data class DefaultInvitePeopleState(
val searchResults: SearchBarResultState<ImmutableList<InvitableUser>>,
val selectedUsers: ImmutableList<MatrixUser>,
override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction<Unit>,
override val eventSink: (InvitePeopleEvents) -> Unit
) : InvitePeopleState

View file

@ -8,6 +8,7 @@
package io.element.android.features.invitepeople.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.user.MatrixUser
@ -68,6 +69,11 @@ internal class DefaultInvitePeopleStateProvider : PreviewParameterProvider<Defau
showSearchLoader = true,
),
aDefaultInvitePeopleState(room = AsyncData.Failure(Exception("Room not found"))),
aDefaultInvitePeopleState(
canInvite = false,
selectedUsers = aMatrixUserList().toImmutableList(),
sendInvitesAction = AsyncAction.Loading,
),
)
}
@ -93,6 +99,7 @@ private fun aDefaultInvitePeopleState(
selectedUsers: ImmutableList<MatrixUser> = persistentListOf(),
isSearchActive: Boolean = false,
showSearchLoader: Boolean = false,
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
): DefaultInvitePeopleState {
return DefaultInvitePeopleState(
room = room,
@ -102,6 +109,7 @@ private fun aDefaultInvitePeopleState(
selectedUsers = selectedUsers,
isSearchActive = isSearchActive,
showSearchLoader = showSearchLoader,
sendInvitesAction = sendInvitesAction,
eventSink = {},
)
}

View file

@ -409,10 +409,23 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
// Send invites
initialState.eventSink(InvitePeopleEvents.SendInvites)
// Can't invite in the loading state
awaitItem().run {
assertThat(sendInvitesAction.isLoading()).isTrue()
assertThat(canInvite).isFalse()
}
delay(1_000)
inviteUserResult.assertions().isCalledOnce().with(
value(selectedUser.userId)
)
// Can invite again once the action is finished
awaitItem().run {
assertThat(sendInvitesAction.isReady()).isTrue()
assertThat(canInvite).isTrue()
}
}
}
@ -445,6 +458,13 @@ internal class DefaultInvitePeoplePresenterTest {
assertThat(resultState.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
// Send invites
initialState.eventSink(InvitePeopleEvents.SendInvites)
// Can't invite in the loading state
awaitItem().run {
assertThat(sendInvitesAction.isLoading()).isTrue()
assertThat(canInvite).isFalse()
}
delay(1_000)
inviteUserResult.assertions().isCalledOnce().with(
value(selectedUser.userId)
@ -455,6 +475,12 @@ internal class DefaultInvitePeoplePresenterTest {
value(CommonStrings.common_unable_to_invite_title),
value(CommonStrings.common_unable_to_invite_message)
)
// Can invite again once the action is finished
awaitItem().run {
assertThat(sendInvitesAction.isReady()).isTrue()
assertThat(canInvite).isTrue()
}
}
}