diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt index 7e816791f6..d475b5bc8c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesEvents.kt @@ -23,5 +23,11 @@ import io.element.android.libraries.matrix.api.core.EventId sealed interface MessagesEvents { data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents data class ToggleReaction(val emoji: String, val eventId: EventId) : MessagesEvents + data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents object Dismiss : MessagesEvents } + +enum class InviteDialogAction { + Cancel, + Invite, +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 6cd2ec62aa..29659d4daa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -21,11 +21,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -52,7 +54,9 @@ import io.element.android.features.messages.impl.utils.messagesummary.MessageSum import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.libraries.androidutils.clipboard.ClipboardHelper +import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -61,6 +65,7 @@ import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.designsystem.utils.collectSnackbarMessageAsState import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.MatrixRoom +import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType @@ -108,6 +113,22 @@ class MessagesPresenter @AssistedInject constructor( mutableStateOf(null) } + var hasDismissedInviteDialog by rememberSaveable { + mutableStateOf(false) + } + + val inviteProgress = remember { mutableStateOf>(Async.Uninitialized) } + + val showReinvitePrompt by remember( + hasDismissedInviteDialog, + composerState.hasFocus, + syncUpdateFlow, + ) { + derivedStateOf { + !hasDismissedInviteDialog && composerState.hasFocus && room.isDirect && room.activeMemberCount == 1L + } + } + val networkConnectionStatus by networkMonitor.connectivity.collectAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() @@ -125,6 +146,7 @@ class MessagesPresenter @AssistedInject constructor( LaunchedEffect(composerState.mode.relatedEventId) { timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId)) } + fun handleEvents(event: MessagesEvents) { when (event) { is MessagesEvents.HandleAction -> { @@ -133,9 +155,17 @@ class MessagesPresenter @AssistedInject constructor( is MessagesEvents.ToggleReaction -> { localCoroutineScope.toggleReaction(event.emoji, event.eventId) } + is MessagesEvents.InviteDialogDismissed -> { + hasDismissedInviteDialog = true + + if (event.action == InviteDialogAction.Invite) { + localCoroutineScope.reinviteOtherUser(inviteProgress) + } + } is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear) } } + return MessagesState( roomId = room.roomId, roomName = roomName.value, @@ -148,6 +178,8 @@ class MessagesPresenter @AssistedInject constructor( retrySendMenuState = retryState, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, snackbarMessage = snackbarMessage, + showReinvitePrompt = showReinvitePrompt, + inviteProgress = inviteProgress.value, eventSink = ::handleEvents ) } @@ -176,8 +208,21 @@ class MessagesPresenter @AssistedInject constructor( .onFailure { Timber.e(it) } } - private fun notImplementedYet() { - Timber.v("NotImplementedYet") + private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState>) = launch(dispatchers.io) { + suspend { + room.updateMembers() + + val memberList = when (val memberState = room.membersStateFlow.value) { + is MatrixRoomMembersState.Ready -> memberState.roomMembers + is MatrixRoomMembersState.Error -> memberState.prevRoomMembers.orEmpty() + else -> emptyList() + } + + val member = memberList.first { it.userId != room.sessionId } + room.inviteUserById(member.userId).onFailure { t -> + Timber.e(t, "Failed to reinvite DM partner") + }.getOrThrow() + }.runCatchingUpdatingState(inviteProgress) } private suspend fun handleActionRedact(event: TimelineItem.Event) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index 38c9101c9e..29fbeaedd9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -22,6 +22,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.timeline.TimelineState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState +import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.matrix.api.core.RoomId @@ -39,5 +40,7 @@ data class MessagesState( val retrySendMenuState: RetrySendMenuState, val hasNetworkConnection: Boolean, val snackbarMessage: SnackbarMessage?, + val inviteProgress: Async, + val showReinvitePrompt: Boolean, val eventSink: (MessagesEvents) -> Unit ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 7811f37db8..69cb0fa493 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.libraries.architecture.Async import io.element.android.libraries.core.data.StableCharSequence import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize @@ -37,6 +38,7 @@ open class MessagesStateProvider : PreviewParameterProvider { aMessagesState().copy(hasNetworkConnection = false), aMessagesState().copy(composerState = aMessageComposerState().copy(showAttachmentSourcePicker = true)), aMessagesState().copy(userHasPermissionToSendMessage = false), + aMessagesState().copy(showReinvitePrompt = true), ) } @@ -64,5 +66,7 @@ fun aMessagesState() = MessagesState( ), hasNetworkConnection = true, snackbarMessage = null, + inviteProgress = Async.Uninitialized, + showReinvitePrompt = false, eventSink = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 0ea9caf29c..550f859581 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -68,6 +68,7 @@ import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.theme.components.Scaffold @@ -184,6 +185,25 @@ fun MessagesView( RetrySendMessageMenu( state = state.retrySendMenuState ) + + ReinviteDialog( + state = state + ) +} + +@Composable +fun ReinviteDialog(state: MessagesState) { + if (state.showReinvitePrompt) { + ConfirmationDialog( + title = stringResource(id = R.string.screen_room_invite_again_alert_title), + content = stringResource(id = R.string.screen_room_invite_again_alert_message), + cancelText = stringResource(id = CommonStrings.action_cancel), + submitText = stringResource(id = CommonStrings.action_invite), + emphasizeSubmitButton = true, + onSubmitClicked = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) }, + onDismiss = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel)) } + ) + } } @Composable diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt index 155e981fcc..cc59ab8c65 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.textcomposer.MessageComposerMode @Immutable sealed interface MessageComposerEvents { object ToggleFullScreenState : MessageComposerEvents + data class FocusChanged(val hasFocus: Boolean) : MessageComposerEvents data class SendMessage(val message: String) : MessageComposerEvents object CloseSpecialMode : MessageComposerEvents data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 39b003e0df..823cd64e5c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -89,6 +89,9 @@ class MessageComposerPresenter @Inject constructor( val isFullScreen = rememberSaveable { mutableStateOf(false) } + val hasFocus = remember { + mutableStateOf(false) + } val text: MutableState = remember { mutableStateOf(StableCharSequence("")) } @@ -115,6 +118,9 @@ class MessageComposerPresenter @Inject constructor( fun handleEvents(event: MessageComposerEvents) { when (event) { MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value + + is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus + is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence() MessageComposerEvents.CloseSpecialMode -> { text.value = "".toStableCharSequence() @@ -158,6 +164,7 @@ class MessageComposerPresenter @Inject constructor( return MessageComposerState( text = text.value, isFullScreen = isFullScreen.value, + hasFocus = hasFocus.value, mode = composerMode.value, showAttachmentSourcePicker = showAttachmentSourcePicker, attachmentsState = attachmentsState.value, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index 9c6a2c650a..570db2faaa 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList data class MessageComposerState( val text: StableCharSequence?, val isFullScreen: Boolean, + val hasFocus: Boolean, val mode: MessageComposerMode, val showAttachmentSourcePicker: Boolean, val attachmentsState: AttachmentsState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index 56d050e7b1..0504d3625a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -30,6 +30,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider = withContext(coroutineDispatchers.io) { val currentState = _membersStateFlow.value val currentMembers = currentState.roomMembers() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index 2555f68226..2020be19d3 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -52,6 +52,7 @@ class FakeMatrixRoom( override val isPublic: Boolean = true, override val isDirect: Boolean = false, override val joinedMemberCount: Long = 123L, + override val activeMemberCount: Long = 234L, private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(), ) : MatrixRoom { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt new file mode 100644 index 0000000000..8d43459aa8 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomMemberFixture.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.test.room + +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.RoomMembershipState + +fun aRoomMember( + userId: UserId = UserId("@alice:server.org"), + displayName: String? = null, + avatarUrl: String? = null, + membership: RoomMembershipState = RoomMembershipState.JOIN, + isNameAmbiguous: Boolean = false, + powerLevel: Long = 0L, + normalizedPowerLevel: Long = 0L, + isIgnored: Boolean = false, +) = RoomMember( + userId = userId, + displayName = displayName, + avatarUrl = avatarUrl, + membership = membership, + isNameAmbiguous = isNameAmbiguous, + powerLevel = powerLevel, + normalizedPowerLevel = normalizedPowerLevel, + isIgnored = isIgnored, +) diff --git a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index 286c2433a3..40179fdb6b 100644 --- a/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusEvent import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.SolidColor @@ -93,6 +94,7 @@ fun TextComposer( onResetComposerMode: () -> Unit = {}, onComposerTextChange: (CharSequence) -> Unit = {}, onAddAttachment: () -> Unit = {}, + onFocusChanged: (Boolean) -> Unit = {}, ) { val text = composerText.orEmpty() Row( @@ -129,7 +131,8 @@ fun TextComposer( modifier = Modifier .fillMaxWidth() .heightIn(min = minHeight) - .focusRequester(focusRequester), + .focusRequester(focusRequester) + .onFocusEvent { onFocusChanged(it.hasFocus) }, value = text, onValueChange = { onComposerTextChange(it) }, onTextLayout = { diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..57d6110ff7 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:442bc38f776ea7270cfd5ef1d57ff49d2086010163fb694eaccb0d708473f16f +size 47476 diff --git a/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..4b17e15b66 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.messages.impl_null_DefaultGroup_MessagesViewLightPreview_0_null_4,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7364d081d95c085a052aa3944420a209f58f37ff6bf246a58b000a3443b2ecbd +size 48451