Merge pull request #302 from ouchadam/feature-leave-room

[Feature] Leave room
This commit is contained in:
Adam Brown 2023-01-07 15:04:03 +00:00 committed by GitHub
commit 449f1bf4c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 154 additions and 10 deletions

View File

@ -67,7 +67,7 @@ internal fun MessengerScreen(
) { ) {
val state = viewModel.current val state = viewModel.current
viewModel.ObserveEvents(galleryLauncher) viewModel.ObserveEvents(galleryLauncher, navigator)
LifecycleEffect( LifecycleEffect(
onStart = { viewModel.dispatch(ComponentLifecycle.Visible) }, onStart = { viewModel.dispatch(ComponentLifecycle.Visible) },
onStop = { viewModel.dispatch(ComponentLifecycle.Gone) } onStop = { viewModel.dispatch(ComponentLifecycle.Gone) }
@ -85,6 +85,30 @@ internal fun MessengerScreen(
onImageClick = { viewModel.dispatch(ComposerStateChange.ImagePreview.Show(it)) } 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 { Column {
Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = { Toolbar(onNavigate = { navigator.navigate.upToHome() }, roomTitle, actions = {
state.roomState.takeIfContent()?.let { state.roomState.takeIfContent()?.let {
@ -98,8 +122,10 @@ internal fun MessengerScreen(
viewModel.dispatch(ScreenAction.Notifications.Mute) 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 @Composable
private fun MessengerState.ObserveEvents(galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>) { private fun MessengerState.ObserveEvents(galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>, navigator: Navigator) {
val context = LocalContext.current val context = LocalContext.current
StartObserving { StartObserving {
this@ObserveEvents.events.launch { this@ObserveEvents.events.launch {
@ -221,6 +247,8 @@ private fun MessengerState.ObserveEvents(galleryLauncher: ActivityResultLauncher
is MessengerEvent.Toast -> { is MessengerEvent.Toast -> {
Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show() Toast.makeText(context, it.message, Toast.LENGTH_SHORT).show()
} }
MessengerEvent.OnLeftRoom -> navigator.navigate.upToHome()
} }
} }
} }

View File

@ -10,11 +10,19 @@ sealed interface ScreenAction : Action {
data class CopyToClipboard(val model: BubbleModel) : ScreenAction data class CopyToClipboard(val model: BubbleModel) : ScreenAction
object SendMessage : ScreenAction object SendMessage : ScreenAction
object OpenGalleryPicker : ScreenAction object OpenGalleryPicker : ScreenAction
object LeaveRoom : ScreenAction
sealed interface Notifications : ScreenAction { sealed interface Notifications : ScreenAction {
object Mute : Notifications object Mute : Notifications
object Unmute : 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 { sealed interface ComponentLifecycle : Action {

View File

@ -19,6 +19,7 @@ import app.dapk.st.navigator.MessageAttachment
import app.dapk.state.* import app.dapk.state.*
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlin.reflect.KClass
internal fun messengerReducer( internal fun messengerReducer(
jobBag: JobBag, jobBag: JobBag,
@ -36,6 +37,7 @@ internal fun messengerReducer(
roomState = Lce.Loading(), roomState = Lce.Loading(),
composerState = initialComposerState(initialAttachments), composerState = initialComposerState(initialAttachments),
viewerState = null, viewerState = null,
dialogState = null,
), ),
async(ComponentLifecycle::class) { action -> 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 <A : Action, S> rewrite(klass: KClass<A>, mapper: (A) -> Action) = async<A, S>(klass) { action -> dispatch(mapper(action)) }
private suspend fun ChatEngine.sendTextMessage(content: MessengerPageState, composerState: ComposerState.Text) { private suspend fun ChatEngine.sendTextMessage(content: MessengerPageState, composerState: ComposerState.Text) {
val roomState = content.roomState val roomState = content.roomState
val message = SendMessage.TextMessage( val message = SendMessage.TextMessage(

View File

@ -1,12 +1,13 @@
package app.dapk.st.messenger.state package app.dapk.st.messenger.state
import app.dapk.st.core.Lce import app.dapk.st.core.Lce
import app.dapk.st.state.State
import app.dapk.st.design.components.BubbleModel import app.dapk.st.design.components.BubbleModel
import app.dapk.st.engine.MessengerPageState import app.dapk.st.engine.MessengerPageState
import app.dapk.st.engine.RoomEvent import app.dapk.st.engine.RoomEvent
import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.RoomId
import app.dapk.st.navigator.MessageAttachment import app.dapk.st.navigator.MessageAttachment
import app.dapk.st.state.State
import app.dapk.state.Action
typealias MessengerState = State<MessengerScreenState, MessengerEvent> typealias MessengerState = State<MessengerScreenState, MessengerEvent>
@ -15,15 +16,26 @@ data class MessengerScreenState(
val roomState: Lce<MessengerPageState>, val roomState: Lce<MessengerPageState>,
val composerState: ComposerState, val composerState: ComposerState,
val viewerState: ViewerState?, val viewerState: ViewerState?,
val dialogState: DialogState?,
) )
data class ViewerState( data class ViewerState(
val event: BubbleModel.Image, 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 { sealed interface MessengerEvent {
object SelectImageAttachment : MessengerEvent object SelectImageAttachment : MessengerEvent
data class Toast(val message: String) : MessengerEvent data class Toast(val message: String) : MessengerEvent
object OnLeftRoom : MessengerEvent
} }
sealed interface ComposerState { sealed interface ComposerState {

View File

@ -15,14 +15,12 @@ import fake.FakeChatEngine
import fake.FakeJobBag import fake.FakeJobBag
import fake.FakeMessageOptionsStore import fake.FakeMessageOptionsStore
import fixture.* import fixture.*
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import org.junit.Test import org.junit.Test
import test.ReducerTestScope import test.*
import test.delegateReturn
import test.expect
import test.testReducer
private const val READ_RECEIPTS_ARE_DISABLED = true private const val READ_RECEIPTS_ARE_DISABLED = true
private val A_ROOM_ID = aRoomId("messenger state room id") private val A_ROOM_ID = aRoomId("messenger state room id")
@ -37,11 +35,16 @@ private val AN_IMAGE_BUBBLE = BubbleModel.Image(
mockk(), mockk(),
BubbleModel.Event("author-id", "author-name", edited = false, time = "10:27") BubbleModel.Event("author-id", "author-name", edited = false, time = "10:27")
) )
private val A_TEXT_BUBBLE = BubbleModel.Text( private val A_TEXT_BUBBLE = BubbleModel.Text(
content = RichText(listOf(RichText.Part.Normal(A_MESSAGE_CONTENT))), content = RichText(listOf(RichText.Part.Normal(A_MESSAGE_CONTENT))),
BubbleModel.Event("author-id", "author-name", edited = false, time = "10:27") 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 { class MessengerReducerTest {
@ -72,6 +75,7 @@ class MessengerReducerTest {
roomState = Lce.Loading(), roomState = Lce.Loading(),
composerState = ComposerState.Text(value = "", reply = null), composerState = ComposerState.Text(value = "", reply = null),
viewerState = null, viewerState = null,
dialogState = null,
) )
) )
} }
@ -84,6 +88,7 @@ class MessengerReducerTest {
roomState = Lce.Loading(), roomState = Lce.Loading(),
composerState = ComposerState.Text(value = "", reply = null), composerState = ComposerState.Text(value = "", reply = null),
viewerState = null, viewerState = null,
dialogState = null,
) )
) )
} }
@ -96,6 +101,7 @@ class MessengerReducerTest {
roomState = Lce.Loading(), roomState = Lce.Loading(),
composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null), composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null),
viewerState = 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 @Test
fun `when OpenGalleryPicker, then emits event`() = runReducerTest { fun `when OpenGalleryPicker, then emits event`() = runReducerTest {
reduce(ScreenAction.OpenGalleryPicker) reduce(ScreenAction.OpenGalleryPicker)

@ -1 +1 @@
Subproject commit d596949ac2b923b02da55ddd78e2e26dc46af82a Subproject commit 337a65b27b9911205e52a87c075be4bbf70a557d