diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt index 0f15864..c46a71d 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerScreen.kt @@ -67,7 +67,7 @@ internal fun MessengerScreen( ) { val state = viewModel.current - viewModel.ObserveEvents(galleryLauncher) + viewModel.ObserveEvents(galleryLauncher, navigator) LifecycleEffect( onStart = { viewModel.dispatch(ComponentLifecycle.Visible) }, onStop = { viewModel.dispatch(ComponentLifecycle.Gone) } @@ -85,6 +85,30 @@ internal fun MessengerScreen( onImageClick = { viewModel.dispatch(ComposerStateChange.ImagePreview.Show(it)) } ) + when (val dialog = state.dialogState) { + null -> { + // do nothing + } + + is DialogState.PositiveNegative -> { + AlertDialog( + onDismissRequest = { viewModel.dispatch(ScreenAction.LeaveRoomConfirmation.Deny) }, + confirmButton = { + Button(onClick = { viewModel.dispatch(ScreenAction.LeaveRoomConfirmation.Confirm) }) { + Text("Leave room") + } + }, + dismissButton = { + Button(onClick = { viewModel.dispatch(ScreenAction.LeaveRoomConfirmation.Deny) }) { + Text("Cancel") + } + }, + title = { Text(dialog.title) }, + text = { Text(dialog.subtitle) } + ) + } + } + Column { Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = { state.roomState.takeIfContent()?.let { @@ -98,8 +122,10 @@ internal fun MessengerScreen( viewModel.dispatch(ScreenAction.Notifications.Mute) }) } + DropdownMenuItem(text = { Text("Leave room", color = MaterialTheme.colorScheme.onSecondaryContainer) }, onClick = { + viewModel.dispatch(ScreenAction.LeaveRoom) + }) } - } }) @@ -207,7 +233,7 @@ private fun ZoomableImage(viewerState: ViewerState) { } @Composable -private fun MessengerState.ObserveEvents(galleryLauncher: ActivityResultLauncher) { +private fun MessengerState.ObserveEvents(galleryLauncher: ActivityResultLauncher, navigator: Navigator) { val context = LocalContext.current StartObserving { this@ObserveEvents.events.launch { @@ -221,6 +247,8 @@ private fun MessengerState.ObserveEvents(galleryLauncher: ActivityResultLauncher is MessengerEvent.Toast -> { Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() } + + MessengerEvent.OnLeftRoom -> navigator.navigate.upToHome() } } } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerAction.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerAction.kt index 07541ca..2cfa295 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerAction.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerAction.kt @@ -10,11 +10,19 @@ sealed interface ScreenAction : Action { data class CopyToClipboard(val model: BubbleModel) : ScreenAction object SendMessage : ScreenAction object OpenGalleryPicker : ScreenAction + object LeaveRoom : ScreenAction sealed interface Notifications : ScreenAction { object Mute : Notifications object Unmute : Notifications } + + sealed interface LeaveRoomConfirmation : ScreenAction { + object Confirm : LeaveRoomConfirmation + object Deny : LeaveRoomConfirmation + } + + data class UpdateDialogState(val dialogState: DialogState?): ScreenAction } sealed interface ComponentLifecycle : Action { diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerReducer.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerReducer.kt index b7e7c8f..a6ae9d7 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerReducer.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerReducer.kt @@ -19,6 +19,7 @@ import app.dapk.st.navigator.MessageAttachment import app.dapk.state.* import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlin.reflect.KClass internal fun messengerReducer( jobBag: JobBag, @@ -36,6 +37,7 @@ internal fun messengerReducer( roomState = Lce.Loading(), composerState = initialComposerState(initialAttachments), viewerState = null, + dialogState = null, ), async(ComponentLifecycle::class) { action -> @@ -159,9 +161,43 @@ internal fun messengerReducer( ) ) }, + + change(ScreenAction.UpdateDialogState::class) { action, state -> + state.copy(dialogState = action.dialogState) + }, + + rewrite(ScreenAction.LeaveRoom::class) { + ScreenAction.UpdateDialogState( + DialogState.PositiveNegative( + title = "Leave room", + subtitle = "Are you sure you want you leave the room? If the room is private you will need to be invited again to rejoin.", + negativeAction = ScreenAction.LeaveRoomConfirmation.Deny, + positiveAction = ScreenAction.LeaveRoomConfirmation.Confirm, + ) + ) + }, + + async(ScreenAction.LeaveRoomConfirmation::class) { action -> + dispatch(ScreenAction.UpdateDialogState(dialogState = null)) + + when (action) { + ScreenAction.LeaveRoomConfirmation.Confirm -> { + runCatching { chatEngine.rejectRoom(getState().roomId) }.fold( + onSuccess = { eventEmitter.invoke(MessengerEvent.OnLeftRoom) }, + onFailure = { eventEmitter.invoke(MessengerEvent.Toast("Failed to leave room")) }, + ) + } + + ScreenAction.LeaveRoomConfirmation.Deny -> { + // do nothing + } + } + }, ) } +private fun rewrite(klass: KClass, mapper: (A) -> Action) = async(klass) { action -> dispatch(mapper(action)) } + private suspend fun ChatEngine.sendTextMessage(content: MessengerPageState, composerState: ComposerState.Text) { val roomState = content.roomState val message = SendMessage.TextMessage( diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerState.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerState.kt index 385767c..095c509 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerState.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerState.kt @@ -1,12 +1,13 @@ package app.dapk.st.messenger.state import app.dapk.st.core.Lce -import app.dapk.st.state.State import app.dapk.st.design.components.BubbleModel import app.dapk.st.engine.MessengerPageState import app.dapk.st.engine.RoomEvent import app.dapk.st.matrix.common.RoomId import app.dapk.st.navigator.MessageAttachment +import app.dapk.st.state.State +import app.dapk.state.Action typealias MessengerState = State @@ -15,15 +16,26 @@ data class MessengerScreenState( val roomState: Lce, val composerState: ComposerState, val viewerState: ViewerState?, + val dialogState: DialogState?, ) data class ViewerState( val event: BubbleModel.Image, ) +sealed interface DialogState { + data class PositiveNegative( + val title: String, + val subtitle: String, + val positiveAction: Action, + val negativeAction: Action, + ) : DialogState +} + sealed interface MessengerEvent { object SelectImageAttachment : MessengerEvent data class Toast(val message: String) : MessengerEvent + object OnLeftRoom : MessengerEvent } sealed interface ComposerState { diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerReducerTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerReducerTest.kt index 5ac645d..b517dd0 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerReducerTest.kt +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerReducerTest.kt @@ -15,14 +15,12 @@ import fake.FakeChatEngine import fake.FakeJobBag import fake.FakeMessageOptionsStore import fixture.* +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.flowOf import org.junit.Test -import test.ReducerTestScope -import test.delegateReturn -import test.expect -import test.testReducer +import test.* private const val READ_RECEIPTS_ARE_DISABLED = true private val A_ROOM_ID = aRoomId("messenger state room id") @@ -37,11 +35,16 @@ private val AN_IMAGE_BUBBLE = BubbleModel.Image( mockk(), BubbleModel.Event("author-id", "author-name", edited = false, time = "10:27") ) - private val A_TEXT_BUBBLE = BubbleModel.Text( content = RichText(listOf(RichText.Part.Normal(A_MESSAGE_CONTENT))), BubbleModel.Event("author-id", "author-name", edited = false, time = "10:27") ) +private val A_DIALOG_STATE = DialogState.PositiveNegative( + "a title", + "a subtitle", + positiveAction = ScreenAction.LeaveRoomConfirmation.Confirm, + negativeAction = ScreenAction.LeaveRoomConfirmation.Deny, +) class MessengerReducerTest { @@ -72,6 +75,7 @@ class MessengerReducerTest { roomState = Lce.Loading(), composerState = ComposerState.Text(value = "", reply = null), viewerState = null, + dialogState = null, ) ) } @@ -84,6 +88,7 @@ class MessengerReducerTest { roomState = Lce.Loading(), composerState = ComposerState.Text(value = "", reply = null), viewerState = null, + dialogState = null, ) ) } @@ -96,6 +101,7 @@ class MessengerReducerTest { roomState = Lce.Loading(), composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null), viewerState = null, + dialogState = null, ) ) } @@ -221,6 +227,60 @@ class MessengerReducerTest { } } + @Test + fun `when LeaveRoom, then updates dialog state with leave room confirmation`() = runReducerTest { + reduce(ScreenAction.LeaveRoom) + + assertOnlyDispatches( + ScreenAction.UpdateDialogState( + DialogState.PositiveNegative( + title = "Leave room", + subtitle = "Are you sure you want you leave the room? If the room is private you will need to be invited again to rejoin.", + negativeAction = ScreenAction.LeaveRoomConfirmation.Deny, + positiveAction = ScreenAction.LeaveRoomConfirmation.Confirm, + ) + ) + ) + } + + @Test + fun `when UpdateDialogState, then updates dialog state`() = runReducerTest { + reduce(ScreenAction.UpdateDialogState(dialogState = A_DIALOG_STATE)) + + assertOnlyStateChange { it.copy(dialogState = A_DIALOG_STATE) } + } + + @Test + fun `given can leave room, when LeaveConfirmation Confirm, then removes dialog and rejects room and emits OnLeftRoom`() = runReducerTest { + fakeChatEngine.expect { it.rejectRoom(A_ROOM_ID) } + + reduce(ScreenAction.LeaveRoomConfirmation.Confirm) + + assertDispatches(ScreenAction.UpdateDialogState(dialogState = null)) + assertEvents(MessengerEvent.OnLeftRoom) + assertNoStateChange() + } + + @Test + fun `given leave room fails, when LeaveConfirmation Confirm, then removes dialog and emits toast`() = runReducerTest { + fakeChatEngine.expectError(error = RuntimeException("an error")) { fakeChatEngine.rejectRoom(A_ROOM_ID) } + + reduce(ScreenAction.LeaveRoomConfirmation.Confirm) + + assertDispatches(ScreenAction.UpdateDialogState(dialogState = null)) + assertEvents(MessengerEvent.Toast("Failed to leave room")) + assertNoStateChange() + } + + @Test + fun `when LeaveConfirmation Deny, then removes dialog and does nothing`() = runReducerTest { + reduce(ScreenAction.LeaveRoomConfirmation.Deny) + + assertDispatches(ScreenAction.UpdateDialogState(dialogState = null)) + assertNoEvents() + assertNoStateChange() + } + @Test fun `when OpenGalleryPicker, then emits event`() = runReducerTest { reduce(ScreenAction.OpenGalleryPicker) diff --git a/screen-state b/screen-state index d596949..337a65b 160000 --- a/screen-state +++ b/screen-state @@ -1 +1 @@ -Subproject commit d596949ac2b923b02da55ddd78e2e26dc46af82a +Subproject commit 337a65b27b9911205e52a87c075be4bbf70a557d