Show a prompt to reinvite other party in a DM
If you are looking at a DM where the other party has left then when you focus the composer for the first time we'll show a dialog asking if you want to reinvite the other party. Closes #590
This commit is contained in:
parent
7d54652dd7
commit
f7d498599b
18 changed files with 327 additions and 6 deletions
|
|
@ -23,5 +23,11 @@ import io.element.android.libraries.matrix.api.core.EventId
|
||||||
sealed interface MessagesEvents {
|
sealed interface MessagesEvents {
|
||||||
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
|
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
|
||||||
data class ToggleReaction(val emoji: String, val eventId: EventId) : MessagesEvents
|
data class ToggleReaction(val emoji: String, val eventId: EventId) : MessagesEvents
|
||||||
|
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
|
||||||
object Dismiss : MessagesEvents
|
object Dismiss : MessagesEvents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class InviteDialogAction {
|
||||||
|
Cancel,
|
||||||
|
Invite,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,13 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedFactory
|
import dagger.assisted.AssistedFactory
|
||||||
import dagger.assisted.AssistedInject
|
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.NetworkMonitor
|
||||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||||
import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
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.Presenter
|
||||||
|
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
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.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
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.designsystem.utils.collectSnackbarMessageAsState
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
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.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.api.room.MessageEventType
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
|
||||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
|
||||||
|
|
@ -108,6 +113,22 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
mutableStateOf(null)
|
mutableStateOf(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var hasDismissedInviteDialog by rememberSaveable {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
val inviteProgress = remember { mutableStateOf<Async<Unit>>(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 networkConnectionStatus by networkMonitor.connectivity.collectAsState()
|
||||||
|
|
||||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||||
|
|
@ -125,6 +146,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
LaunchedEffect(composerState.mode.relatedEventId) {
|
LaunchedEffect(composerState.mode.relatedEventId) {
|
||||||
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
|
timelineState.eventSink(TimelineEvents.SetHighlightedEvent(composerState.mode.relatedEventId))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvents(event: MessagesEvents) {
|
fun handleEvents(event: MessagesEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is MessagesEvents.HandleAction -> {
|
is MessagesEvents.HandleAction -> {
|
||||||
|
|
@ -133,9 +155,17 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
is MessagesEvents.ToggleReaction -> {
|
is MessagesEvents.ToggleReaction -> {
|
||||||
localCoroutineScope.toggleReaction(event.emoji, event.eventId)
|
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)
|
is MessagesEvents.Dismiss -> actionListState.eventSink(ActionListEvents.Clear)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return MessagesState(
|
return MessagesState(
|
||||||
roomId = room.roomId,
|
roomId = room.roomId,
|
||||||
roomName = roomName.value,
|
roomName = roomName.value,
|
||||||
|
|
@ -148,6 +178,8 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
retrySendMenuState = retryState,
|
retrySendMenuState = retryState,
|
||||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
|
showReinvitePrompt = showReinvitePrompt,
|
||||||
|
inviteProgress = inviteProgress.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -176,8 +208,21 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
.onFailure { Timber.e(it) }
|
.onFailure { Timber.e(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun notImplementedYet() {
|
private fun CoroutineScope.reinviteOtherUser(inviteProgress: MutableState<Async<Unit>>) = launch(dispatchers.io) {
|
||||||
Timber.v("NotImplementedYet")
|
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) {
|
private suspend fun handleActionRedact(event: TimelineItem.Event) {
|
||||||
|
|
|
||||||
|
|
@ -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.TimelineState
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
|
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.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.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.SnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
import io.element.android.libraries.matrix.api.core.RoomId
|
||||||
|
|
@ -39,5 +40,7 @@ data class MessagesState(
|
||||||
val retrySendMenuState: RetrySendMenuState,
|
val retrySendMenuState: RetrySendMenuState,
|
||||||
val hasNetworkConnection: Boolean,
|
val hasNetworkConnection: Boolean,
|
||||||
val snackbarMessage: SnackbarMessage?,
|
val snackbarMessage: SnackbarMessage?,
|
||||||
|
val inviteProgress: Async<Unit>,
|
||||||
|
val showReinvitePrompt: Boolean,
|
||||||
val eventSink: (MessagesEvents) -> Unit
|
val eventSink: (MessagesEvents) -> Unit
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.customreaction.CustomReactionState
|
||||||
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
|
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.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.core.data.StableCharSequence
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
|
|
@ -37,6 +38,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
|
||||||
aMessagesState().copy(hasNetworkConnection = false),
|
aMessagesState().copy(hasNetworkConnection = false),
|
||||||
aMessagesState().copy(composerState = aMessageComposerState().copy(showAttachmentSourcePicker = true)),
|
aMessagesState().copy(composerState = aMessageComposerState().copy(showAttachmentSourcePicker = true)),
|
||||||
aMessagesState().copy(userHasPermissionToSendMessage = false),
|
aMessagesState().copy(userHasPermissionToSendMessage = false),
|
||||||
|
aMessagesState().copy(showReinvitePrompt = true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,5 +66,7 @@ fun aMessagesState() = MessagesState(
|
||||||
),
|
),
|
||||||
hasNetworkConnection = true,
|
hasNetworkConnection = true,
|
||||||
snackbarMessage = null,
|
snackbarMessage = null,
|
||||||
|
inviteProgress = Async.Uninitialized,
|
||||||
|
showReinvitePrompt = false,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.Avatar
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
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.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.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
|
@ -184,6 +185,25 @@ fun MessagesView(
|
||||||
RetrySendMessageMenu(
|
RetrySendMessageMenu(
|
||||||
state = state.retrySendMenuState
|
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
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import io.element.android.libraries.textcomposer.MessageComposerMode
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface MessageComposerEvents {
|
sealed interface MessageComposerEvents {
|
||||||
object ToggleFullScreenState : MessageComposerEvents
|
object ToggleFullScreenState : MessageComposerEvents
|
||||||
|
data class FocusChanged(val hasFocus: Boolean) : MessageComposerEvents
|
||||||
data class SendMessage(val message: String) : MessageComposerEvents
|
data class SendMessage(val message: String) : MessageComposerEvents
|
||||||
object CloseSpecialMode : MessageComposerEvents
|
object CloseSpecialMode : MessageComposerEvents
|
||||||
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,9 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
val isFullScreen = rememberSaveable {
|
val isFullScreen = rememberSaveable {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
|
val hasFocus = remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
val text: MutableState<StableCharSequence> = remember {
|
val text: MutableState<StableCharSequence> = remember {
|
||||||
mutableStateOf(StableCharSequence(""))
|
mutableStateOf(StableCharSequence(""))
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +118,9 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
fun handleEvents(event: MessageComposerEvents) {
|
fun handleEvents(event: MessageComposerEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
|
||||||
|
|
||||||
|
is MessageComposerEvents.FocusChanged -> hasFocus.value = event.hasFocus
|
||||||
|
|
||||||
is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence()
|
is MessageComposerEvents.UpdateText -> text.value = event.text.toStableCharSequence()
|
||||||
MessageComposerEvents.CloseSpecialMode -> {
|
MessageComposerEvents.CloseSpecialMode -> {
|
||||||
text.value = "".toStableCharSequence()
|
text.value = "".toStableCharSequence()
|
||||||
|
|
@ -158,6 +164,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
return MessageComposerState(
|
return MessageComposerState(
|
||||||
text = text.value,
|
text = text.value,
|
||||||
isFullScreen = isFullScreen.value,
|
isFullScreen = isFullScreen.value,
|
||||||
|
hasFocus = hasFocus.value,
|
||||||
mode = composerMode.value,
|
mode = composerMode.value,
|
||||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||||
attachmentsState = attachmentsState.value,
|
attachmentsState = attachmentsState.value,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||||
data class MessageComposerState(
|
data class MessageComposerState(
|
||||||
val text: StableCharSequence?,
|
val text: StableCharSequence?,
|
||||||
val isFullScreen: Boolean,
|
val isFullScreen: Boolean,
|
||||||
|
val hasFocus: Boolean,
|
||||||
val mode: MessageComposerMode,
|
val mode: MessageComposerMode,
|
||||||
val showAttachmentSourcePicker: Boolean,
|
val showAttachmentSourcePicker: Boolean,
|
||||||
val attachmentsState: AttachmentsState,
|
val attachmentsState: AttachmentsState,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider<MessageCompos
|
||||||
fun aMessageComposerState() = MessageComposerState(
|
fun aMessageComposerState() = MessageComposerState(
|
||||||
text = StableCharSequence(""),
|
text = StableCharSequence(""),
|
||||||
isFullScreen = false,
|
isFullScreen = false,
|
||||||
|
hasFocus = false,
|
||||||
mode = MessageComposerMode.Normal(content = ""),
|
mode = MessageComposerMode.Normal(content = ""),
|
||||||
showAttachmentSourcePicker = false,
|
showAttachmentSourcePicker = false,
|
||||||
attachmentsState = AttachmentsState.None,
|
attachmentsState = AttachmentsState.None,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ fun MessageComposerView(
|
||||||
state.eventSink(MessageComposerEvents.SendMessage(message))
|
state.eventSink(MessageComposerEvents.SendMessage(message))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onAddAttachment() {
|
||||||
|
state.eventSink(MessageComposerEvents.AddAttachment)
|
||||||
|
}
|
||||||
|
|
||||||
fun onCloseSpecialMode() {
|
fun onCloseSpecialMode() {
|
||||||
state.eventSink(MessageComposerEvents.CloseSpecialMode)
|
state.eventSink(MessageComposerEvents.CloseSpecialMode)
|
||||||
}
|
}
|
||||||
|
|
@ -46,6 +50,10 @@ fun MessageComposerView(
|
||||||
state.eventSink(MessageComposerEvents.UpdateText(text))
|
state.eventSink(MessageComposerEvents.UpdateText(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onFocusChanged(hasFocus: Boolean) {
|
||||||
|
state.eventSink(MessageComposerEvents.FocusChanged(hasFocus))
|
||||||
|
}
|
||||||
|
|
||||||
Box {
|
Box {
|
||||||
AttachmentsBottomSheet(state = state)
|
AttachmentsBottomSheet(state = state)
|
||||||
|
|
||||||
|
|
@ -54,9 +62,8 @@ fun MessageComposerView(
|
||||||
composerMode = state.mode,
|
composerMode = state.mode,
|
||||||
onResetComposerMode = ::onCloseSpecialMode,
|
onResetComposerMode = ::onCloseSpecialMode,
|
||||||
onComposerTextChange = ::onComposerTextChange,
|
onComposerTextChange = ::onComposerTextChange,
|
||||||
onAddAttachment = {
|
onAddAttachment = ::onAddAttachment,
|
||||||
state.eventSink(MessageComposerEvents.AddAttachment)
|
onFocusChanged = ::onFocusChanged,
|
||||||
},
|
|
||||||
composerCanSendMessage = state.isSendButtonVisible,
|
composerCanSendMessage = state.isSendButtonVisible,
|
||||||
composerText = state.text?.charSequence?.toString(),
|
composerText = state.text?.charSequence?.toString(),
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,13 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.analytics.test.FakeAnalyticsService
|
import io.element.android.features.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.features.messages.fixtures.aMessageEvent
|
import io.element.android.features.messages.fixtures.aMessageEvent
|
||||||
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
|
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
|
||||||
|
import io.element.android.features.messages.impl.InviteDialogAction
|
||||||
import io.element.android.features.messages.impl.MessagesEvents
|
import io.element.android.features.messages.impl.MessagesEvents
|
||||||
import io.element.android.features.messages.impl.MessagesPresenter
|
import io.element.android.features.messages.impl.MessagesPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
|
||||||
import io.element.android.features.messages.impl.actionlist.ActionListState
|
import io.element.android.features.messages.impl.actionlist.ActionListState
|
||||||
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
|
||||||
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
import io.element.android.features.messages.impl.timeline.TimelinePresenter
|
||||||
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionPresenter
|
||||||
|
|
@ -49,11 +51,16 @@ import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
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.api.room.MessageEventType
|
||||||
|
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
import io.element.android.libraries.matrix.test.room.aRoomMember
|
||||||
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
import io.element.android.libraries.mediapickers.test.FakePickerProvider
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||||
|
|
@ -349,6 +356,170 @@ class MessagesPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - shows prompt to reinvite users in DM`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(sessionId = A_SESSION_ID, isDirect = true, activeMemberCount = 1L)
|
||||||
|
val presenter = createMessagePresenter(matrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(3)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
|
||||||
|
// Initially the composer doesn't have focus, so we don't show the alert
|
||||||
|
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||||
|
|
||||||
|
// When the input field is focused we show the alert
|
||||||
|
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
|
||||||
|
val focusedState = awaitItem()
|
||||||
|
assertThat(focusedState.showReinvitePrompt).isTrue()
|
||||||
|
|
||||||
|
// If it's dismissed then we stop showing the alert
|
||||||
|
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel))
|
||||||
|
val dismissedState = awaitItem()
|
||||||
|
assertThat(dismissedState.showReinvitePrompt).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - doesn't show reinvite prompt in non-direct room`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(sessionId = A_SESSION_ID, isDirect = false, activeMemberCount = 1L)
|
||||||
|
val presenter = createMessagePresenter(matrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(3)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||||
|
|
||||||
|
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
|
||||||
|
val focusedState = awaitItem()
|
||||||
|
assertThat(focusedState.showReinvitePrompt).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - doesn't show reinvite prompt if other party is present`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(sessionId = A_SESSION_ID, isDirect = true, activeMemberCount = 2L)
|
||||||
|
val presenter = createMessagePresenter(matrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
skipItems(3)
|
||||||
|
val initialState = awaitItem()
|
||||||
|
assertThat(initialState.showReinvitePrompt).isFalse()
|
||||||
|
|
||||||
|
initialState.composerState.eventSink(MessageComposerEvents.FocusChanged(true))
|
||||||
|
val focusedState = awaitItem()
|
||||||
|
assertThat(focusedState.showReinvitePrompt).isFalse()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle reinviting other user when memberlist is ready`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
|
||||||
|
room.givenRoomMembersState(
|
||||||
|
MatrixRoomMembersState.Ready(
|
||||||
|
listOf(
|
||||||
|
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
|
||||||
|
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val presenter = createMessagePresenter(matrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||||
|
skipItems(3)
|
||||||
|
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
|
||||||
|
|
||||||
|
val newState = awaitItem()
|
||||||
|
assertThat(newState.inviteProgress.isSuccess()).isTrue()
|
||||||
|
assertThat(room.invitedUserId).isEqualTo(A_SESSION_ID_2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle reinviting other user when memberlist is error`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
|
||||||
|
room.givenRoomMembersState(
|
||||||
|
MatrixRoomMembersState.Error(
|
||||||
|
failure = Throwable(),
|
||||||
|
prevRoomMembers = listOf(
|
||||||
|
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
|
||||||
|
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val presenter = createMessagePresenter(matrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||||
|
skipItems(3)
|
||||||
|
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
|
||||||
|
|
||||||
|
val newState = awaitItem()
|
||||||
|
assertThat(newState.inviteProgress.isSuccess()).isTrue()
|
||||||
|
assertThat(room.invitedUserId).isEqualTo(A_SESSION_ID_2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle reinviting other user when memberlist is not ready`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
|
||||||
|
room.givenRoomMembersState(MatrixRoomMembersState.Unknown)
|
||||||
|
val presenter = createMessagePresenter(matrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||||
|
skipItems(3)
|
||||||
|
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
|
||||||
|
|
||||||
|
val newState = awaitItem()
|
||||||
|
assertThat(newState.inviteProgress.isFailure()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - handle reinviting other user when inviting fails`() = runTest {
|
||||||
|
val room = FakeMatrixRoom(sessionId = A_SESSION_ID)
|
||||||
|
room.givenRoomMembersState(
|
||||||
|
MatrixRoomMembersState.Ready(
|
||||||
|
listOf(
|
||||||
|
aRoomMember(userId = A_SESSION_ID, membership = RoomMembershipState.JOIN),
|
||||||
|
aRoomMember(userId = A_SESSION_ID_2, membership = RoomMembershipState.LEAVE),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
room.givenInviteUserResult(Result.failure(Throwable("Oops!")))
|
||||||
|
val presenter = createMessagePresenter(matrixRoom = room)
|
||||||
|
moleculeFlow(RecompositionClock.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
val initialState = awaitItem()
|
||||||
|
initialState.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite))
|
||||||
|
skipItems(3)
|
||||||
|
|
||||||
|
val loadingState = awaitItem()
|
||||||
|
assertThat(loadingState.inviteProgress.isLoading()).isTrue()
|
||||||
|
|
||||||
|
val newState = awaitItem()
|
||||||
|
assertThat(newState.inviteProgress.isFailure()).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - permission to post`() = runTest {
|
fun `present - permission to post`() = runTest {
|
||||||
val matrixRoom = FakeMatrixRoom()
|
val matrixRoom = FakeMatrixRoom()
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ interface MatrixRoom : Closeable {
|
||||||
val isEncrypted: Boolean
|
val isEncrypted: Boolean
|
||||||
val isDirect: Boolean
|
val isDirect: Boolean
|
||||||
val isPublic: Boolean
|
val isPublic: Boolean
|
||||||
|
val activeMemberCount: Long
|
||||||
val joinedMemberCount: Long
|
val joinedMemberCount: Long
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,9 @@ class RustMatrixRoom(
|
||||||
override val joinedMemberCount: Long
|
override val joinedMemberCount: Long
|
||||||
get() = innerRoom.joinedMembersCount().toLong()
|
get() = innerRoom.joinedMembersCount().toLong()
|
||||||
|
|
||||||
|
override val activeMemberCount: Long
|
||||||
|
get() = innerRoom.activeMembersCount().toLong()
|
||||||
|
|
||||||
override suspend fun updateMembers(): Result<Unit> = withContext(coroutineDispatchers.io) {
|
override suspend fun updateMembers(): Result<Unit> = withContext(coroutineDispatchers.io) {
|
||||||
val currentState = _membersStateFlow.value
|
val currentState = _membersStateFlow.value
|
||||||
val currentMembers = currentState.roomMembers()
|
val currentMembers = currentState.roomMembers()
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ class FakeMatrixRoom(
|
||||||
override val isPublic: Boolean = true,
|
override val isPublic: Boolean = true,
|
||||||
override val isDirect: Boolean = false,
|
override val isDirect: Boolean = false,
|
||||||
override val joinedMemberCount: Long = 123L,
|
override val joinedMemberCount: Long = 123L,
|
||||||
|
override val activeMemberCount: Long = 234L,
|
||||||
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
|
private val matrixTimeline: MatrixTimeline = FakeMatrixTimeline(),
|
||||||
) : MatrixRoom {
|
) : MatrixRoom {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -53,6 +53,7 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
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.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
|
@ -93,6 +94,7 @@ fun TextComposer(
|
||||||
onResetComposerMode: () -> Unit = {},
|
onResetComposerMode: () -> Unit = {},
|
||||||
onComposerTextChange: (CharSequence) -> Unit = {},
|
onComposerTextChange: (CharSequence) -> Unit = {},
|
||||||
onAddAttachment: () -> Unit = {},
|
onAddAttachment: () -> Unit = {},
|
||||||
|
onFocusChanged: (Boolean) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val text = composerText.orEmpty()
|
val text = composerText.orEmpty()
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -129,7 +131,8 @@ fun TextComposer(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.heightIn(min = minHeight)
|
.heightIn(min = minHeight)
|
||||||
.focusRequester(focusRequester),
|
.focusRequester(focusRequester)
|
||||||
|
.onFocusEvent { onFocusChanged(it.hasFocus) },
|
||||||
value = text,
|
value = text,
|
||||||
onValueChange = { onComposerTextChange(it) },
|
onValueChange = { onComposerTextChange(it) },
|
||||||
onTextLayout = {
|
onTextLayout = {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:442bc38f776ea7270cfd5ef1d57ff49d2086010163fb694eaccb0d708473f16f
|
||||||
|
size 47476
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7364d081d95c085a052aa3944420a209f58f37ff6bf246a58b000a3443b2ecbd
|
||||||
|
size 48451
|
||||||
Loading…
Add table
Add a link
Reference in a new issue