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

@@ -7,8 +7,11 @@
package io.element.android.features.invitepeople.api
import io.element.android.libraries.architecture.AsyncAction
interface InvitePeopleState {
val canInvite: Boolean
val isSearchActive: Boolean
val sendInvitesAction: AsyncAction<Unit>
val eventSink: (InvitePeopleEvents) -> Unit
}

View File

@@ -8,28 +8,33 @@
package io.element.android.features.invitepeople.api
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
class InvitePeopleStateProvider : PreviewParameterProvider<InvitePeopleState> {
override val values: Sequence<InvitePeopleState>
get() = sequenceOf(
aPreviewInvitePeopleState(),
aPreviewInvitePeopleState(canInvite = true),
aPreviewInvitePeopleState(isSearchActive = true)
aPreviewInvitePeopleState(isSearchActive = true),
aPreviewInvitePeopleState(sendInvitesAction = AsyncAction.Loading),
)
}
private data class PreviewInvitePeopleState(
override val canInvite: Boolean,
override val isSearchActive: Boolean,
override val sendInvitesAction: AsyncAction<Unit>,
override val eventSink: (InvitePeopleEvents) -> Unit,
) : InvitePeopleState
private fun aPreviewInvitePeopleState(
canInvite: Boolean = false,
isSearchActive: Boolean = false,
sendInvitesAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (InvitePeopleEvents) -> Unit = {},
) = PreviewInvitePeopleState(
canInvite = canInvite,
isSearchActive = isSearchActive,
sendInvitesAction = sendInvitesAction,
eventSink = eventSink
)

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()
}
}
}

View File

@@ -8,6 +8,7 @@
package io.element.android.features.roomdetails.impl.invite
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
@@ -49,11 +50,18 @@ class RoomInviteMembersNode(
@Composable
override fun View(modifier: Modifier) {
val state = invitePeoplePresenter.present()
// Once invites have been sent successfully, close the Invite view.
LaunchedEffect(state.sendInvitesAction) {
if (state.sendInvitesAction.isReady()) {
navigateUp()
}
}
RoomInviteMembersView(
state = state,
modifier = modifier,
onBackClick = { navigateUp() },
onDone = { navigateUp() }
onBackClick = { navigateUp() }
) {
invitePeopleRenderer.Render(state, Modifier)
}

View File

@@ -8,22 +8,29 @@
package io.element.android.features.roomdetails.impl.invite
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.invitepeople.api.InvitePeopleEvents
import io.element.android.features.invitepeople.api.InvitePeopleState
import io.element.android.features.invitepeople.api.InvitePeopleStateProvider
import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.ui.strings.CommonStrings
@@ -32,7 +39,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
fun RoomInviteMembersView(
state: InvitePeopleState,
onBackClick: () -> Unit,
onDone: () -> Unit,
modifier: Modifier = Modifier,
invitePeopleView: @Composable () -> Unit,
) {
@@ -49,7 +55,6 @@ fun RoomInviteMembersView(
},
onSubmitClick = {
state.eventSink(InvitePeopleEvents.SendInvites)
onDone()
},
canSend = state.canInvite,
)
@@ -64,6 +69,10 @@ fun RoomInviteMembersView(
invitePeopleView()
}
}
if (state.sendInvitesAction.isLoading()) {
InviteProgressDialog()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -86,6 +95,24 @@ private fun RoomInviteMembersTopBar(
)
}
@Composable
private fun InviteProgressDialog() {
ProgressDialog {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.screen_room_details_invite_people_preparing),
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontHeadingSmMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.screen_room_details_invite_people_dont_close),
color = ElementTheme.colors.textSecondary,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@PreviewsDayNight
@Composable
internal fun RoomInviteMembersViewPreview(@PreviewParameter(InvitePeopleStateProvider::class) state: InvitePeopleState) = ElementPreview {
@@ -93,6 +120,5 @@ internal fun RoomInviteMembersViewPreview(@PreviewParameter(InvitePeopleStatePro
state = state,
invitePeopleView = {},
onBackClick = {},
onDone = {},
)
}

View File

@@ -50,6 +50,8 @@
<string name="screen_room_details_error_loading_notification_settings">"An error occurred when loading notification settings."</string>
<string name="screen_room_details_error_muting">"Failed muting this room, please try again."</string>
<string name="screen_room_details_error_unmuting">"Failed unmuting this room, please try again."</string>
<string name="screen_room_details_invite_people_dont_close">"Don\'t close the app until finished."</string>
<string name="screen_room_details_invite_people_preparing">"Preparing invitations…"</string>
<string name="screen_room_details_invite_people_title">"Invite people"</string>
<string name="screen_room_details_leave_conversation_title">"Leave conversation"</string>
<string name="screen_room_details_leave_room_title">"Leave room"</string>

View File

@@ -38,6 +38,18 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
/**
* A progress dialog, with a spinner, and optional text content.
*
* @param modifier
* @param text Optional text to show under the spinner.
* @param type
* @param properties
* @param showCancelButton
* @param onDismissRequest
* @param content Optional additional content to show under the spinner, and above the cancel button (if shown). If both `text` and `content` are supplied,
* `text` is shown above `content`.
*/
@Composable
fun ProgressDialog(
modifier: Modifier = Modifier,
@@ -46,6 +58,7 @@ fun ProgressDialog(
properties: DialogProperties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
showCancelButton: Boolean = false,
onDismissRequest: () -> Unit = {},
content: @Composable () -> Unit = {},
) {
DisposableEffect(Unit) {
onDispose {
@@ -75,7 +88,8 @@ fun ProgressDialog(
)
}
}
}
},
content,
)
}
}
@@ -96,7 +110,8 @@ private fun ProgressDialogContent(
CircularProgressIndicator(
color = ElementTheme.colors.iconPrimary
)
}
},
content: @Composable () -> Unit,
) {
Box(
contentAlignment = Alignment.Center,
@@ -118,6 +133,7 @@ private fun ProgressDialogContent(
color = ElementTheme.colors.textPrimary,
)
}
content()
if (showCancelButton) {
Spacer(modifier = Modifier.height(24.dp))
Box(
@@ -138,7 +154,7 @@ private fun ProgressDialogContent(
@Composable
internal fun ProgressDialogContentPreview() = ElementThemedPreview {
DialogPreview {
ProgressDialogContent(text = "test dialog content", showCancelButton = true)
ProgressDialogContent(text = "test dialog content", showCancelButton = true, content = {})
}
}
@@ -147,3 +163,34 @@ internal fun ProgressDialogContentPreview() = ElementThemedPreview {
internal fun ProgressDialogPreview() = ElementPreview {
ProgressDialog(text = "test dialog content", showCancelButton = true)
}
@PreviewsDayNight
@Composable
internal fun ProgressDialogWithContentPreview() = ElementPreview {
ProgressDialog(showCancelButton = true) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Heading",
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontHeadingSmMedium,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Subtext",
color = ElementTheme.colors.textSecondary,
style = MaterialTheme.typography.bodyMedium,
)
}
}
@PreviewsDayNight
@Composable
internal fun ProgressDialogWithTextAndContentPreview() = ElementPreview {
ProgressDialog(text = "Text Content") {
Text(
text = "blah blah",
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontHeadingSmMedium,
)
}
}

View File

@@ -304,6 +304,7 @@ Reason: %1$s."</string>
<string name="common_sent">"Sent"</string>
<string name="common_sentence_delimiter">". "</string>
<string name="common_server_not_supported">"Server not supported"</string>
<string name="common_server_unreachable">"Server unreachable"</string>
<string name="common_server_url">"Server URL"</string>
<string name="common_settings">"Settings"</string>
<string name="common_shared_location">"Shared location"</string>

View File

@@ -114,6 +114,8 @@ class KonsistPreviewTest {
"PollContentViewDisclosedPreview",
"PollContentViewEndedPreview",
"PollContentViewUndisclosedPreview",
"ProgressDialogWithContentPreview",
"ProgressDialogWithTextAndContentPreview",
"ReadReceiptBottomSheetPreview",
"RoomMemberListViewBannedPreview",
"SasEmojisPreview",