diff --git a/chat-engine/src/main/kotlin/app/dapk/st/engine/ChatEngine.kt b/chat-engine/src/main/kotlin/app/dapk/st/engine/ChatEngine.kt index fd88d3d..5d75fbb 100644 --- a/chat-engine/src/main/kotlin/app/dapk/st/engine/ChatEngine.kt +++ b/chat-engine/src/main/kotlin/app/dapk/st/engine/ChatEngine.kt @@ -11,7 +11,7 @@ interface ChatEngine : TaskRunner { fun directory(): Flow fun invites(): Flow - fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow + fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow fun notificationsMessages(): Flow fun notificationsInvites(): Flow diff --git a/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt b/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt index 7a6e25a..167591e 100644 --- a/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt +++ b/chat-engine/src/main/kotlin/app/dapk/st/engine/Models.kt @@ -84,7 +84,7 @@ sealed interface ImportResult { data class Update(val importedKeysCount: Long) : ImportResult } -data class MessengerState( +data class MessengerPageState( val self: UserId, val roomState: RoomState, val typing: Typing? diff --git a/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt b/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt index b3c903f..829902e 100644 --- a/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt +++ b/chat-engine/src/testFixtures/kotlin/fixture/Fixtures.kt @@ -7,7 +7,7 @@ fun aMessengerState( self: UserId = aUserId(), roomState: RoomState, typing: Typing? = null -) = MessengerState(self, roomState, typing) +) = MessengerPageState(self, roomState, typing) fun aRoomOverview( roomId: RoomId = aRoomId(), diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/state/JobBag.kt b/core/src/main/kotlin/app/dapk/st/core/JobBag.kt similarity index 86% rename from features/directory/src/main/kotlin/app/dapk/st/directory/state/JobBag.kt rename to core/src/main/kotlin/app/dapk/st/core/JobBag.kt index b9d3b07..18066b4 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/state/JobBag.kt +++ b/core/src/main/kotlin/app/dapk/st/core/JobBag.kt @@ -1,4 +1,4 @@ -package app.dapk.st.directory.state +package app.dapk.st.core import kotlinx.coroutines.Job diff --git a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt index c43a033..d935abf 100644 --- a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt +++ b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/ActivityExtensions.kt @@ -24,7 +24,7 @@ inline fun ComponentActivity.viewModel( inline fun ComponentActivity.state( - noinline factory: () -> StateViewModel + noinline factory: () -> State ): Lazy> { val factoryPromise = object : Factory { @Suppress("UNCHECKED_CAST") diff --git a/domains/state/src/main/kotlin/app/dapk/state/State.kt b/domains/state/src/main/kotlin/app/dapk/state/State.kt index 268ea0c..d65dfcf 100644 --- a/domains/state/src/main/kotlin/app/dapk/state/State.kt +++ b/domains/state/src/main/kotlin/app/dapk/state/State.kt @@ -14,8 +14,9 @@ fun createStore(reducerFactory: ReducerFactory, coroutineScope: Coroutine override suspend fun dispatch(action: Action) { scope.coroutineScope.launch { state = reducer.reduce(action).also { nextState -> - println("!!! next state: $nextState") - subscribers.forEach { it.invoke(nextState) } + if (nextState != state) { + subscribers.forEach { it.invoke(nextState) } + } } } } diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt index 6dd5c5f..58a95ee 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/DirectoryModule.kt @@ -2,11 +2,9 @@ package app.dapk.st.directory import android.content.Context import app.dapk.st.core.ProvidableModule -import app.dapk.st.core.StateViewModel import app.dapk.st.core.createStateViewModel -import app.dapk.st.directory.state.DirectoryEvent -import app.dapk.st.directory.state.DirectoryScreenState -import app.dapk.st.directory.state.JobBag +import app.dapk.st.core.JobBag +import app.dapk.st.directory.state.DirectoryState import app.dapk.st.directory.state.directoryReducer import app.dapk.st.engine.ChatEngine @@ -15,7 +13,7 @@ class DirectoryModule( private val chatEngine: ChatEngine, ) : ProvidableModule { - fun directoryViewModel(): StateViewModel { + fun directoryState(): DirectoryState { return createStateViewModel { directoryReducer(chatEngine, ShortcutHandler(context), JobBag(), it) } } } diff --git a/features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt b/features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt index a4dfe69..bd997f8 100644 --- a/features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt +++ b/features/directory/src/main/kotlin/app/dapk/st/directory/state/DirectoryReducer.kt @@ -1,5 +1,6 @@ package app.dapk.st.directory.state +import app.dapk.st.core.JobBag import app.dapk.st.directory.ShortcutHandler import app.dapk.st.engine.ChatEngine import app.dapk.state.* diff --git a/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt index d801076..ce6fe82 100644 --- a/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt +++ b/features/directory/src/test/kotlin/app/dapk/st/directory/DirectoryReducerTest.kt @@ -1,9 +1,9 @@ package app.dapk.st.directory +import app.dapk.st.core.JobBag import app.dapk.st.directory.state.* import app.dapk.st.engine.DirectoryItem import app.dapk.st.engine.UnreadCount -import app.dapk.state.ReducerFactory import fake.FakeChatEngine import fixture.aRoomOverview import io.mockk.mockk diff --git a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt index 455f98f..f8d86a3 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt @@ -13,10 +13,9 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope import app.dapk.st.core.DapkActivity import app.dapk.st.core.module -import app.dapk.st.core.viewModel import app.dapk.st.core.state +import app.dapk.st.core.viewModel import app.dapk.st.directory.DirectoryModule -import app.dapk.st.directory.state.DirectoryState import app.dapk.st.login.LoginModule import app.dapk.st.profile.ProfileModule import kotlinx.coroutines.flow.launchIn @@ -24,7 +23,7 @@ import kotlinx.coroutines.flow.onEach class MainActivity : DapkActivity() { - private val directoryViewModel: DirectoryState by state { module().directoryViewModel() } + private val directoryViewModel by state { module().directoryState() } private val loginViewModel by viewModel { module().loginViewModel() } private val profileViewModel by viewModel { module().profileViewModel() } private val homeViewModel by viewModel { module().homeViewModel(directoryViewModel, loginViewModel, profileViewModel) } diff --git a/features/messenger/build.gradle b/features/messenger/build.gradle index f9edf4e..59d3ed8 100644 --- a/features/messenger/build.gradle +++ b/features/messenger/build.gradle @@ -6,6 +6,7 @@ dependencies { implementation project(":domains:android:compose-core") implementation project(":domains:android:viewmodel") implementation project(":domains:store") + implementation project(":domains:state") implementation project(":core") implementation project(":features:navigator") implementation project(":design-library") @@ -16,6 +17,7 @@ dependencies { androidImportFixturesWorkaround(project, project(":matrix:common")) androidImportFixturesWorkaround(project, project(":core")) androidImportFixturesWorkaround(project, project(":domains:store")) + androidImportFixturesWorkaround(project, project(":domains:state")) androidImportFixturesWorkaround(project, project(":domains:android:viewmodel")) androidImportFixturesWorkaround(project, project(":domains:android:stub")) androidImportFixturesWorkaround(project, project(":chat-engine")) diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt index 045e6dc..69f8730 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerActivity.kt @@ -14,6 +14,10 @@ import app.dapk.st.core.* import app.dapk.st.core.extensions.unsafeLazy import app.dapk.st.matrix.common.RoomId import app.dapk.st.messenger.gallery.GetImageFromGallery +import app.dapk.st.messenger.state.ComposerStateChange +import app.dapk.st.messenger.state.MessengerEvent +import app.dapk.st.messenger.state.MessengerScreenState +import app.dapk.st.messenger.state.MessengerState import app.dapk.st.navigator.MessageAttachment import coil.request.ImageRequest import kotlinx.parcelize.Parcelize @@ -23,7 +27,7 @@ val LocalImageRequestFactory = staticCompositionLocalOf { class MessengerActivity : DapkActivity() { private val module by unsafeLazy { module() } - private val viewModel by viewModel { module.messengerViewModel() } + private val state by state { module.messengerState(readPayload()) } companion object { @@ -54,8 +58,8 @@ class MessengerActivity : DapkActivity() { val galleryLauncher = registerForActivityResult(GetImageFromGallery()) { it?.let { uri -> - viewModel.post( - MessengerAction.ComposerImageUpdate( + state.dispatch( + ComposerStateChange.SelectAttachmentToSend( MessageAttachment( AndroidUri(it.toString()), MimeType.Image, @@ -68,7 +72,7 @@ class MessengerActivity : DapkActivity() { setContent { Surface(Modifier.fillMaxSize()) { CompositionLocalProvider(LocalImageRequestFactory provides factory) { - MessengerScreen(RoomId(payload.roomId), payload.attachments, viewModel, navigator, galleryLauncher) + MessengerScreen(state, navigator, galleryLauncher) } } } diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt index bcb5ad4..50f12ab 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerModule.kt @@ -3,10 +3,14 @@ package app.dapk.st.messenger import android.content.ClipboardManager import android.content.Context import app.dapk.st.core.DeviceMeta +import app.dapk.st.core.JobBag import app.dapk.st.core.ProvidableModule +import app.dapk.st.core.createStateViewModel import app.dapk.st.domain.application.message.MessageOptionsStore import app.dapk.st.engine.ChatEngine import app.dapk.st.matrix.common.RoomId +import app.dapk.st.messenger.state.MessengerState +import app.dapk.st.messenger.state.messengerReducer class MessengerModule( private val chatEngine: ChatEngine, @@ -15,13 +19,19 @@ class MessengerModule( private val deviceMeta: DeviceMeta, ) : ProvidableModule { - internal fun messengerViewModel(): MessengerViewModel { - return MessengerViewModel( - chatEngine, - messageOptionsStore, - CopyToClipboard(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager), - deviceMeta, - ) + internal fun messengerState(launchPayload: MessagerActivityPayload): MessengerState { + return createStateViewModel { + messengerReducer( + JobBag(), + chatEngine, + CopyToClipboard(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager), + deviceMeta, + messageOptionsStore, + RoomId(launchPayload.roomId), + launchPayload.attachments, + it + ) + } } internal fun decryptingFetcherFactory(roomId: RoomId) = DecryptingFetcherFactory(context, roomId, chatEngine.mediaDecrypter()) 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 f6f4773..a661231 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 @@ -45,14 +45,13 @@ import app.dapk.st.core.components.CenteredLoading import app.dapk.st.core.extensions.takeIfContent import app.dapk.st.design.components.* import app.dapk.st.engine.MessageMeta -import app.dapk.st.engine.MessengerState +import app.dapk.st.engine.MessengerPageState import app.dapk.st.engine.RoomEvent import app.dapk.st.engine.RoomState import app.dapk.st.matrix.common.RichText -import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.UserId import app.dapk.st.messenger.gallery.ImageGalleryActivityPayload -import app.dapk.st.navigator.MessageAttachment +import app.dapk.st.messenger.state.* import app.dapk.st.navigator.Navigator import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest @@ -62,18 +61,16 @@ import kotlin.math.roundToInt @Composable internal fun MessengerScreen( - roomId: RoomId, - attachments: List?, - viewModel: MessengerViewModel, + viewModel: MessengerState, navigator: Navigator, galleryLauncher: ActivityResultLauncher ) { - val state = viewModel.state + val state = viewModel.current viewModel.ObserveEvents(galleryLauncher) LifecycleEffect( - onStart = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) }, - onStop = { viewModel.post(MessengerAction.OnMessengerGone) } + onStart = { viewModel.dispatch(ComponentLifecycle.Visible) }, + onStop = { viewModel.dispatch(ComponentLifecycle.Gone) } ) val roomTitle = when (val roomState = state.roomState) { @@ -82,10 +79,10 @@ internal fun MessengerScreen( } val messageActions = MessageActions( - onReply = { viewModel.post(MessengerAction.ComposerEnterReplyMode(it)) }, - onDismiss = { viewModel.post(MessengerAction.ComposerExitReplyMode) }, - onLongClick = { viewModel.post(MessengerAction.CopyToClipboard(it)) }, - onImageClick = { viewModel.selectImage(it) } + onReply = { viewModel.dispatch(ComposerStateChange.ReplyMode.Enter(it)) }, + onDismiss = { viewModel.dispatch(ComposerStateChange.ReplyMode.Exit) }, + onLongClick = { viewModel.dispatch(ScreenAction.CopyToClipboard(it)) }, + onImageClick = { viewModel.dispatch(ComposerStateChange.ImagePreview.Show(it)) } ) Column { @@ -97,12 +94,12 @@ internal fun MessengerScreen( when (state.composerState) { is ComposerState.Text -> { - Room(state.roomState, messageActions, onRetry = { viewModel.post(MessengerAction.OnMessengerVisible(roomId, attachments)) }) + Room(state.roomState, messageActions, onRetry = { viewModel.dispatch(ComponentLifecycle.Visible) }) TextComposer( state.composerState, - onTextChange = { viewModel.post(MessengerAction.ComposerTextUpdate(it)) }, - onSend = { viewModel.post(MessengerAction.ComposerSendText) }, - onAttach = { viewModel.startAttachment() }, + onTextChange = { viewModel.dispatch(ComposerStateChange.TextUpdate(it)) }, + onSend = { viewModel.dispatch(ScreenAction.SendMessage) }, + onAttach = { viewModel.dispatch(ScreenAction.OpenGalleryPicker) }, messageActions = messageActions, ) } @@ -110,8 +107,8 @@ internal fun MessengerScreen( is ComposerState.Attachments -> { AttachmentComposer( state.composerState, - onSend = { viewModel.post(MessengerAction.ComposerSendText) }, - onCancel = { viewModel.post(MessengerAction.ComposerClear) } + onSend = { viewModel.dispatch(ScreenAction.SendMessage) }, + onCancel = { viewModel.dispatch(ComposerStateChange.Clear) } ) } } @@ -124,10 +121,10 @@ internal fun MessengerScreen( else -> { Box(Modifier.fillMaxSize().background(Color.Black)) { - BackHandler(onBack = { viewModel.unselectImage() }) + BackHandler(onBack = { viewModel.dispatch(ComposerStateChange.ImagePreview.Hide) }) ZoomableImage(state.viewerState) Toolbar( - onNavigate = { viewModel.unselectImage() }, + onNavigate = { viewModel.dispatch(ComposerStateChange.ImagePreview.Hide) }, title = state.viewerState.event.event.authorName, color = Color.Black.copy(alpha = 0.4f), ) @@ -199,13 +196,13 @@ private fun ZoomableImage(viewerState: ViewerState) { } @Composable -private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLauncher) { +private fun MessengerState.ObserveEvents(galleryLauncher: ActivityResultLauncher) { val context = LocalContext.current StartObserving { this@ObserveEvents.events.launch { when (it) { MessengerEvent.SelectImageAttachment -> { - state.roomState.takeIfContent()?.let { + current.roomState.takeIfContent()?.let { galleryLauncher.launch(ImageGalleryActivityPayload(it.roomState.roomOverview.roomName ?: "")) } } @@ -219,7 +216,7 @@ private fun MessengerViewModel.ObserveEvents(galleryLauncher: ActivityResultLaun } @Composable -private fun ColumnScope.Room(roomStateLce: Lce, messageActions: MessageActions, onRetry: () -> Unit) { +private fun ColumnScope.Room(roomStateLce: Lce, messageActions: MessageActions, onRetry: () -> Unit) { when (val state = roomStateLce) { is Lce.Loading -> CenteredLoading() is Lce.Content -> { diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt deleted file mode 100644 index 460257d..0000000 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerViewModel.kt +++ /dev/null @@ -1,197 +0,0 @@ -package app.dapk.st.messenger - -import android.os.Build -import androidx.lifecycle.viewModelScope -import app.dapk.st.core.DeviceMeta -import app.dapk.st.core.Lce -import app.dapk.st.core.asString -import app.dapk.st.core.extensions.takeIfContent -import app.dapk.st.design.components.BubbleModel -import app.dapk.st.domain.application.message.MessageOptionsStore -import app.dapk.st.engine.ChatEngine -import app.dapk.st.engine.RoomEvent -import app.dapk.st.engine.SendMessage -import app.dapk.st.matrix.common.RoomId -import app.dapk.st.matrix.common.asString -import app.dapk.st.navigator.MessageAttachment -import app.dapk.st.viewmodel.DapkViewModel -import app.dapk.st.viewmodel.MutableStateFactory -import app.dapk.st.viewmodel.defaultStateFactory -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch - -internal class MessengerViewModel( - private val chatEngine: ChatEngine, - private val messageOptionsStore: MessageOptionsStore, - private val copyToClipboard: CopyToClipboard, - private val deviceMeta: DeviceMeta, - factory: MutableStateFactory = defaultStateFactory(), -) : DapkViewModel( - initialState = MessengerScreenState( - roomId = null, - roomState = Lce.Loading(), - composerState = ComposerState.Text(value = "", reply = null), - viewerState = null, - ), - factory = factory, -) { - - private var syncJob: Job? = null - - fun post(action: MessengerAction) { - when (action) { - is MessengerAction.OnMessengerVisible -> start(action) - MessengerAction.OnMessengerGone -> syncJob?.cancel() - is MessengerAction.ComposerTextUpdate -> updateState { copy(composerState = ComposerState.Text(action.newValue, composerState.reply)) } - MessengerAction.ComposerSendText -> sendMessage() - MessengerAction.ComposerClear -> resetComposer() - is MessengerAction.ComposerImageUpdate -> updateState { - copy( - composerState = ComposerState.Attachments( - listOf(action.newValue), - composerState.reply - ) - ) - } - - is MessengerAction.ComposerEnterReplyMode -> updateState { - copy( - composerState = when (composerState) { - is ComposerState.Attachments -> composerState.copy(reply = action.replyingTo) - is ComposerState.Text -> composerState.copy(reply = action.replyingTo) - } - ) - } - - MessengerAction.ComposerExitReplyMode -> updateState { - copy( - composerState = when (composerState) { - is ComposerState.Attachments -> composerState.copy(reply = null) - is ComposerState.Text -> composerState.copy(reply = null) - } - ) - } - - is MessengerAction.CopyToClipboard -> { - viewModelScope.launch { - when (val result = action.model.findCopyableContent()) { - is CopyableResult.Content -> { - copyToClipboard.copy(result.value) - if (deviceMeta.apiVersion <= Build.VERSION_CODES.S_V2) { - _events.emit(MessengerEvent.Toast("Copied to clipboard")) - } - } - - CopyableResult.NothingToCopy -> _events.emit(MessengerEvent.Toast("Nothing to copy")) - } - } - } - } - } - - private fun start(action: MessengerAction.OnMessengerVisible) { - updateState { copy(roomId = action.roomId, composerState = action.attachments?.let { ComposerState.Attachments(it, null) } ?: composerState) } - viewModelScope.launch { - syncJob = chatEngine.messages(action.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled()) - .onEach { updateState { copy(roomState = Lce.Content(it)) } } - .launchIn(this) - } - } - - - private fun sendMessage() { - when (val composerState = state.composerState) { - is ComposerState.Text -> { - val copy = composerState.copy() - updateState { copy(composerState = composerState.copy(value = "", reply = null)) } - - state.roomState.takeIfContent()?.let { content -> - val roomState = content.roomState - viewModelScope.launch { - chatEngine.send( - message = SendMessage.TextMessage( - content = copy.value, - reply = copy.reply?.let { - SendMessage.TextMessage.Reply( - author = it.author, - originalMessage = when (it) { - is RoomEvent.Image -> TODO() - is RoomEvent.Reply -> TODO() - is RoomEvent.Message -> it.content.asString() - is RoomEvent.Encrypted -> error("Should never happen") - }, - eventId = it.eventId, - timestampUtc = it.utcTimestamp, - ) - } - ), - room = roomState.roomOverview, - ) - } - } - } - - is ComposerState.Attachments -> { - val copy = composerState.copy() - resetComposer() - - state.roomState.takeIfContent()?.let { content -> - val roomState = content.roomState - viewModelScope.launch { - chatEngine.send(SendMessage.ImageMessage(uri = copy.values.first().uri.value), roomState.roomOverview) - } - } - - } - } - } - - private fun resetComposer() { - updateState { copy(composerState = ComposerState.Text("", reply = null)) } - } - - fun startAttachment() { - viewModelScope.launch { - _events.emit(MessengerEvent.SelectImageAttachment) - } - } - - fun selectImage(image: BubbleModel.Image) { - updateState { - copy(viewerState = ViewerState(image)) - } - } - - fun unselectImage() { - updateState { - copy(viewerState = null) - } - } - -} - -private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) { - is BubbleModel.Encrypted -> CopyableResult.NothingToCopy - is BubbleModel.Image -> CopyableResult.NothingToCopy - is BubbleModel.Reply -> this.reply.findCopyableContent() - is BubbleModel.Text -> CopyableResult.Content(CopyToClipboard.Copyable.Text(this.content.asString())) -} - -private sealed interface CopyableResult { - object NothingToCopy : CopyableResult - data class Content(val value: CopyToClipboard.Copyable) : CopyableResult -} - -sealed interface MessengerAction { - data class ComposerTextUpdate(val newValue: String) : MessengerAction - data class ComposerEnterReplyMode(val replyingTo: RoomEvent) : MessengerAction - object ComposerExitReplyMode : MessengerAction - data class CopyToClipboard(val model: BubbleModel) : MessengerAction - data class ComposerImageUpdate(val newValue: MessageAttachment) : MessengerAction - object ComposerSendText : MessengerAction - object ComposerClear : MessengerAction - data class OnMessengerVisible(val roomId: RoomId, val attachments: List?) : MessengerAction - object OnMessengerGone : MessengerAction -} \ No newline at end of file diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt index 0844d46..3daa7c0 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/roomsettings/RoomSettingsActivity.kt @@ -9,16 +9,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import app.dapk.st.core.DapkActivity -import app.dapk.st.core.module -import app.dapk.st.core.viewModel import app.dapk.st.matrix.common.RoomId -import app.dapk.st.messenger.MessengerModule import kotlinx.parcelize.Parcelize class RoomSettingsActivity : DapkActivity() { - private val viewModel by viewModel { module().messengerViewModel() } - companion object { fun newInstance(context: Context, roomId: RoomId): Intent { return Intent(context, RoomSettingsActivity::class.java).apply { 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 new file mode 100644 index 0000000..4dc63e1 --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerAction.kt @@ -0,0 +1,38 @@ +package app.dapk.st.messenger.state + +import app.dapk.st.design.components.BubbleModel +import app.dapk.st.engine.MessengerPageState +import app.dapk.st.engine.RoomEvent +import app.dapk.st.navigator.MessageAttachment +import app.dapk.state.Action + +sealed interface ScreenAction : Action { + data class CopyToClipboard(val model: BubbleModel) : ScreenAction + object SendMessage : ScreenAction + object OpenGalleryPicker : ScreenAction +} + +sealed interface ComponentLifecycle : Action { + object Visible : ComponentLifecycle + object Gone : ComponentLifecycle +} + +sealed interface MessagesStateChange : Action { + data class Content(val content: MessengerPageState) : ComposerStateChange +} + +sealed interface ComposerStateChange : Action { + data class SelectAttachmentToSend(val newValue: MessageAttachment) : ComposerStateChange + data class TextUpdate(val newValue: String) : ComposerStateChange + object Clear : ComposerStateChange + + sealed interface ReplyMode : ComposerStateChange { + data class Enter(val replyingTo: RoomEvent) : ReplyMode + object Exit : ReplyMode + } + + sealed interface ImagePreview : ComposerStateChange { + data class Show(val image: BubbleModel.Image) : ImagePreview + object Hide : ImagePreview + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..e94a1ab --- /dev/null +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerReducer.kt @@ -0,0 +1,169 @@ +package app.dapk.st.messenger.state + +import android.os.Build +import app.dapk.st.core.DeviceMeta +import app.dapk.st.core.JobBag +import app.dapk.st.core.Lce +import app.dapk.st.core.asString +import app.dapk.st.core.extensions.takeIfContent +import app.dapk.st.design.components.BubbleModel +import app.dapk.st.domain.application.message.MessageOptionsStore +import app.dapk.st.engine.ChatEngine +import app.dapk.st.engine.RoomEvent +import app.dapk.st.engine.SendMessage +import app.dapk.st.matrix.common.RoomId +import app.dapk.st.matrix.common.asString +import app.dapk.st.messenger.CopyToClipboard +import app.dapk.st.navigator.MessageAttachment +import app.dapk.state.* +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +internal fun messengerReducer( + jobBag: JobBag, + chatEngine: ChatEngine, + copyToClipboard: CopyToClipboard, + deviceMeta: DeviceMeta, + messageOptionsStore: MessageOptionsStore, + roomId: RoomId, + initialAttachments: List?, + eventEmitter: suspend (MessengerEvent) -> Unit, +): ReducerFactory { + return createReducer( + initialState = MessengerScreenState( + roomId = roomId, + roomState = Lce.Loading(), + composerState = initialAttachments?.let { ComposerState.Attachments(it, null) } ?: ComposerState.Text(value = "", reply = null), + viewerState = null, + ), + + async(ComponentLifecycle::class) { action -> + val state = getState() + when (action) { + is ComponentLifecycle.Visible -> { + jobBag.add("messages", chatEngine.messages(state.roomId, disableReadReceipts = messageOptionsStore.isReadReceiptsDisabled()) + .onEach { dispatch(MessagesStateChange.Content(it)) } + .launchIn(coroutineScope) + ) + } + + ComponentLifecycle.Gone -> jobBag.cancel("messages") + } + }, + + change(MessagesStateChange.Content::class) { action, state -> + state.copy(roomState = Lce.Content(action.content)) + }, + + change(ComposerStateChange.SelectAttachmentToSend::class) { action, state -> + state.copy( + composerState = ComposerState.Attachments( + listOf(action.newValue), + state.composerState.reply, + ) + ) + }, + + change(ComposerStateChange.ImagePreview::class) { action, state -> + when (action) { + is ComposerStateChange.ImagePreview.Show -> state.copy(viewerState = ViewerState(action.image)) + ComposerStateChange.ImagePreview.Hide -> state.copy(viewerState = null) + } + }, + + change(ComposerStateChange.TextUpdate::class) { action, state -> + state.copy(composerState = ComposerState.Text(action.newValue, state.composerState.reply)) + }, + + change(ComposerStateChange.Clear::class) { _, state -> + state.copy(composerState = ComposerState.Text("", reply = null)) + }, + + change(ComposerStateChange.ReplyMode::class) { action, state -> + when (action) { + is ComposerStateChange.ReplyMode.Enter -> state.copy( + composerState = when (state.composerState) { + is ComposerState.Attachments -> state.composerState.copy(reply = action.replyingTo) + is ComposerState.Text -> state.composerState.copy(reply = action.replyingTo) + } + ) + + ComposerStateChange.ReplyMode.Exit -> state.copy( + composerState = when (state.composerState) { + is ComposerState.Attachments -> state.composerState.copy(reply = null) + is ComposerState.Text -> state.composerState.copy(reply = null) + } + ) + } + }, + + sideEffect(ScreenAction.CopyToClipboard::class) { action, state -> + when (val result = action.model.findCopyableContent()) { + is CopyableResult.Content -> { + copyToClipboard.copy(result.value) + if (deviceMeta.apiVersion <= Build.VERSION_CODES.S_V2) { + eventEmitter.invoke(MessengerEvent.Toast("Copied to clipboard")) + } + } + + CopyableResult.NothingToCopy -> eventEmitter.invoke(MessengerEvent.Toast("Nothing to copy")) + } + }, + + sideEffect(ScreenAction.OpenGalleryPicker::class) { _, _ -> + eventEmitter.invoke(MessengerEvent.SelectImageAttachment) + }, + + async(ScreenAction.SendMessage::class) { + val state = getState() + when (val composerState = state.composerState) { + is ComposerState.Text -> { + dispatch(ComposerStateChange.Clear) + state.roomState.takeIfContent()?.let { content -> + val roomState = content.roomState + chatEngine.send( + message = SendMessage.TextMessage( + content = composerState.value, + reply = composerState.reply?.let { + SendMessage.TextMessage.Reply( + author = it.author, + originalMessage = when (it) { + is RoomEvent.Image -> TODO() + is RoomEvent.Reply -> TODO() + is RoomEvent.Message -> it.content.asString() + is RoomEvent.Encrypted -> error("Should never happen") + }, + eventId = it.eventId, + timestampUtc = it.utcTimestamp, + ) + } + ), + room = roomState.roomOverview, + ) + } + } + + is ComposerState.Attachments -> { + dispatch(ComposerStateChange.Clear) + + state.roomState.takeIfContent()?.let { content -> + val roomState = content.roomState + chatEngine.send(SendMessage.ImageMessage(uri = composerState.values.first().uri.value), roomState.roomOverview) + } + } + } + }, + ) +} + +private fun BubbleModel.findCopyableContent(): CopyableResult = when (this) { + is BubbleModel.Encrypted -> CopyableResult.NothingToCopy + is BubbleModel.Image -> CopyableResult.NothingToCopy + is BubbleModel.Reply -> this.reply.findCopyableContent() + is BubbleModel.Text -> CopyableResult.Content(CopyToClipboard.Copyable.Text(this.content.asString())) +} + +private sealed interface CopyableResult { + object NothingToCopy : CopyableResult + data class Content(val value: CopyToClipboard.Copyable) : CopyableResult +} diff --git a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerState.kt similarity index 76% rename from features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt rename to features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerState.kt index 60657a9..caeea8d 100644 --- a/features/messenger/src/main/kotlin/app/dapk/st/messenger/MessengerState.kt +++ b/features/messenger/src/main/kotlin/app/dapk/st/messenger/state/MessengerState.kt @@ -1,15 +1,18 @@ -package app.dapk.st.messenger +package app.dapk.st.messenger.state import app.dapk.st.core.Lce +import app.dapk.st.core.State import app.dapk.st.design.components.BubbleModel -import app.dapk.st.engine.MessengerState +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 +typealias MessengerState = State + data class MessengerScreenState( - val roomId: RoomId?, - val roomState: Lce, + val roomId: RoomId, + val roomState: Lce, val composerState: ComposerState, val viewerState: ViewerState? ) diff --git a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt index c152194..59e7efb 100644 --- a/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt +++ b/features/messenger/src/test/kotlin/app/dapk/st/messenger/MessengerViewModelTest.kt @@ -4,12 +4,14 @@ import ViewModelTest import app.dapk.st.core.DeviceMeta import app.dapk.st.core.Lce import app.dapk.st.core.extensions.takeIfContent -import app.dapk.st.engine.MessengerState +import app.dapk.st.engine.MessengerPageState import app.dapk.st.engine.RoomState import app.dapk.st.engine.SendMessage import app.dapk.st.matrix.common.EventId import app.dapk.st.matrix.common.RoomId import app.dapk.st.matrix.common.UserId +import app.dapk.st.messenger.state.ComposerState +import app.dapk.st.messenger.state.MessengerScreenState import fake.FakeChatEngine import fake.FakeMessageOptionsStore import fixture.* @@ -112,7 +114,7 @@ class MessengerViewModelTest { } -fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState( +fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerPageState, messageContent: String?) = MessengerScreenState( roomId = roomId, roomState = Lce.Content(roomState), composerState = ComposerState.Text(value = messageContent ?: "", reply = null), diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt index 71981fc..fce1c4c 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/MatrixEngine.kt @@ -48,7 +48,7 @@ class MatrixEngine internal constructor( override fun directory() = directoryUseCase.value.state() override fun invites() = inviteUseCase.value.invites() - override fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow { + override fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow { return timelineUseCase.value.fetch(roomId, isReadReceiptsDisabled = disableReadReceipts) } diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ReadMarkingTimeline.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ReadMarkingTimeline.kt index 0e0d5f8..1a2480f 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ReadMarkingTimeline.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/ReadMarkingTimeline.kt @@ -19,7 +19,7 @@ class ReadMarkingTimeline( private val roomService: RoomService, ) { - fun fetch(roomId: RoomId, isReadReceiptsDisabled: Boolean): Flow { + fun fetch(roomId: RoomId, isReadReceiptsDisabled: Boolean): Flow { return flow { val credentials = credentialsStore.credentials()!! roomStore.markRead(roomId) @@ -37,7 +37,7 @@ class ReadMarkingTimeline( } } - private suspend fun updateRoomReadStateAsync(latestReadEvent: EventId, state: MessengerState, isReadReceiptsDisabled: Boolean): Deferred<*> { + private suspend fun updateRoomReadStateAsync(latestReadEvent: EventId, state: MessengerPageState, isReadReceiptsDisabled: Boolean): Deferred<*> { return coroutineScope { async { runCatching { @@ -50,7 +50,7 @@ class ReadMarkingTimeline( } -private fun MessengerState.latestMessageEventFromOthers(self: UserId) = this.roomState.events +private fun MessengerPageState.latestMessageEventFromOthers(self: UserId) = this.roomState.events .filterIsInstance() .filterNot { it.author.id == self } .firstOrNull() diff --git a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt index b25cbe4..2b79e0d 100644 --- a/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt +++ b/matrix-chat-engine/src/main/kotlin/app/dapk/st/engine/TimelineUseCase.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map -internal typealias ObserveTimelineUseCase = (RoomId, UserId) -> Flow +internal typealias ObserveTimelineUseCase = (RoomId, UserId) -> Flow internal class TimelineUseCaseImpl( private val syncService: SyncService, @@ -19,13 +19,13 @@ internal class TimelineUseCaseImpl( private val mergeWithLocalEchosUseCase: MergeWithLocalEchosUseCase ) : ObserveTimelineUseCase { - override fun invoke(roomId: RoomId, userId: UserId): Flow { + override fun invoke(roomId: RoomId, userId: UserId): Flow { return combine( roomDatasource(roomId), messageService.localEchos(roomId), syncService.events(roomId) ) { roomState, localEchos, events -> - MessengerState( + MessengerPageState( roomState = when { localEchos.isEmpty() -> roomState else -> { diff --git a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt index ae5b47e..9775a0d 100644 --- a/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt +++ b/matrix-chat-engine/src/test/kotlin/app/dapk/st/engine/TimelineUseCaseTest.kt @@ -135,4 +135,4 @@ fun aMessengerState( self: UserId = aUserId(), roomState: app.dapk.st.engine.RoomState, typing: Typing? = null -) = MessengerState(self, roomState, typing) \ No newline at end of file +) = MessengerPageState(self, roomState, typing) \ No newline at end of file