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:
committed by
GitHub
parent
06e7bc1604
commit
5f920eb100
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -114,6 +114,8 @@ class KonsistPreviewTest {
|
||||
"PollContentViewDisclosedPreview",
|
||||
"PollContentViewEndedPreview",
|
||||
"PollContentViewUndisclosedPreview",
|
||||
"ProgressDialogWithContentPreview",
|
||||
"ProgressDialogWithTextAndContentPreview",
|
||||
"ReadReceiptBottomSheetPreview",
|
||||
"RoomMemberListViewBannedPreview",
|
||||
"SasEmojisPreview",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user