mirror of
https://github.com/ouchadam/small-talk.git
synced 2025-03-24 16:00:15 +01:00
Merge pull request #233 from ouchadam/tech/redux-pattern
Tech/redux pattern
This commit is contained in:
commit
72fa795d38
@ -11,7 +11,7 @@ interface ChatEngine : TaskRunner {
|
||||
|
||||
fun directory(): Flow<DirectoryState>
|
||||
fun invites(): Flow<InviteState>
|
||||
fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow<MessengerState>
|
||||
fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow<MessengerPageState>
|
||||
|
||||
fun notificationsMessages(): Flow<UnreadNotifications>
|
||||
fun notificationsInvites(): Flow<InviteNotification>
|
||||
|
@ -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?
|
||||
|
@ -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(),
|
||||
|
17
core/src/main/kotlin/app/dapk/st/core/JobBag.kt
Normal file
17
core/src/main/kotlin/app/dapk/st/core/JobBag.kt
Normal file
@ -0,0 +1,17 @@
|
||||
package app.dapk.st.core
|
||||
|
||||
import kotlinx.coroutines.Job
|
||||
|
||||
class JobBag {
|
||||
|
||||
private val jobs = mutableMapOf<String, Job>()
|
||||
|
||||
fun add(key: String, job: Job) {
|
||||
jobs[key] = job
|
||||
}
|
||||
|
||||
fun cancel(key: String) {
|
||||
jobs.remove(key)?.cancel()
|
||||
}
|
||||
|
||||
}
|
@ -5,4 +5,5 @@ dependencies {
|
||||
implementation project(":features:navigator")
|
||||
implementation project(":design-library")
|
||||
api project(":domains:android:core")
|
||||
api project(":domains:state")
|
||||
}
|
||||
|
@ -3,7 +3,11 @@ package app.dapk.st.core
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelLazy
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelProvider.*
|
||||
import androidx.lifecycle.ViewModelStore
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
inline fun <reified VM : ViewModel> ComponentActivity.viewModel(
|
||||
noinline factory: () -> VM
|
||||
@ -17,3 +21,53 @@ inline fun <reified VM : ViewModel> ComponentActivity.viewModel(
|
||||
}
|
||||
return ViewModelLazy(VM::class, { viewModelStore }, { factoryPromise })
|
||||
}
|
||||
|
||||
|
||||
inline fun <reified S, E> ComponentActivity.state(
|
||||
noinline factory: () -> State<S, E>
|
||||
): Lazy<State<S, E>> {
|
||||
val factoryPromise = object : Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return when(modelClass) {
|
||||
StateViewModel::class.java -> factory() as T
|
||||
else -> throw Error()
|
||||
}
|
||||
}
|
||||
}
|
||||
return KeyedViewModelLazy(
|
||||
key = S::class.java.canonicalName!!,
|
||||
StateViewModel::class,
|
||||
{ viewModelStore },
|
||||
{ factoryPromise }
|
||||
) as Lazy<State<S, E>>
|
||||
}
|
||||
|
||||
class KeyedViewModelLazy<VM : ViewModel> @JvmOverloads constructor(
|
||||
private val key: String,
|
||||
private val viewModelClass: KClass<VM>,
|
||||
private val storeProducer: () -> ViewModelStore,
|
||||
private val factoryProducer: () -> ViewModelProvider.Factory,
|
||||
) : Lazy<VM> {
|
||||
private var cached: VM? = null
|
||||
|
||||
override val value: VM
|
||||
get() {
|
||||
val viewModel = cached
|
||||
return if (viewModel == null) {
|
||||
val factory = factoryProducer()
|
||||
val store = storeProducer()
|
||||
ViewModelProvider(
|
||||
store,
|
||||
factory,
|
||||
CreationExtras.Empty
|
||||
).get(key, viewModelClass.java).also {
|
||||
cached = it
|
||||
}
|
||||
} else {
|
||||
viewModel
|
||||
}
|
||||
}
|
||||
|
||||
override fun isInitialized(): Boolean = cached != null
|
||||
}
|
@ -0,0 +1,49 @@
|
||||
package app.dapk.st.core
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.state.Action
|
||||
import app.dapk.state.ReducerFactory
|
||||
import app.dapk.state.Store
|
||||
import app.dapk.state.createStore
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class StateViewModel<S, E>(
|
||||
reducerFactory: ReducerFactory<S>,
|
||||
eventSource: MutableSharedFlow<E>,
|
||||
) : ViewModel(), State<S, E> {
|
||||
|
||||
private val store: Store<S> = createStore(reducerFactory, viewModelScope)
|
||||
override val events: SharedFlow<E> = eventSource
|
||||
override val current
|
||||
get() = _state!!
|
||||
private var _state: S by mutableStateOf(store.getState())
|
||||
|
||||
init {
|
||||
_state = store.getState()
|
||||
store.subscribe {
|
||||
_state = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatch(action: Action) {
|
||||
viewModelScope.launch { store.dispatch(action) }
|
||||
}
|
||||
}
|
||||
|
||||
fun <S, E> createStateViewModel(block: (suspend (E) -> Unit) -> ReducerFactory<S>): StateViewModel<S, E> {
|
||||
val eventSource = MutableSharedFlow<E>(extraBufferCapacity = 1)
|
||||
val reducer = block { eventSource.emit(it) }
|
||||
return StateViewModel(reducer, eventSource)
|
||||
}
|
||||
|
||||
interface State<S, E> {
|
||||
fun dispatch(action: Action)
|
||||
val events: SharedFlow<E>
|
||||
val current: S
|
||||
}
|
@ -25,4 +25,3 @@ abstract class DapkViewModel<S, VE>(initialState: S, factory: MutableStateFactor
|
||||
state = reducer(state)
|
||||
}
|
||||
}
|
||||
|
||||
|
14
domains/state/build.gradle
Normal file
14
domains/state/build.gradle
Normal file
@ -0,0 +1,14 @@
|
||||
plugins {
|
||||
id 'kotlin'
|
||||
id 'java-test-fixtures'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation Dependencies.mavenCentral.kotlinCoroutinesCore
|
||||
|
||||
testFixturesImplementation testFixtures(project(":core"))
|
||||
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesCore
|
||||
testFixturesImplementation Dependencies.mavenCentral.kluent
|
||||
testFixturesImplementation Dependencies.mavenCentral.mockk
|
||||
testFixturesImplementation Dependencies.mavenCentral.kotlinCoroutinesTest
|
||||
}
|
149
domains/state/src/main/kotlin/app/dapk/state/State.kt
Normal file
149
domains/state/src/main/kotlin/app/dapk/state/State.kt
Normal file
@ -0,0 +1,149 @@
|
||||
package app.dapk.state
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
fun <S> createStore(reducerFactory: ReducerFactory<S>, coroutineScope: CoroutineScope): Store<S> {
|
||||
val subscribers = mutableListOf<(S) -> Unit>()
|
||||
var state: S = reducerFactory.initialState()
|
||||
return object : Store<S> {
|
||||
private val scope = createScope(coroutineScope, this)
|
||||
private val reducer = reducerFactory.create(scope)
|
||||
|
||||
override suspend fun dispatch(action: Action) {
|
||||
scope.coroutineScope.launch {
|
||||
state = reducer.reduce(action).also { nextState ->
|
||||
if (nextState != state) {
|
||||
subscribers.forEach { it.invoke(nextState) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getState() = state
|
||||
|
||||
override fun subscribe(subscriber: (S) -> Unit) {
|
||||
subscribers.add(subscriber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ReducerFactory<S> {
|
||||
fun create(scope: ReducerScope<S>): Reducer<S>
|
||||
fun initialState(): S
|
||||
}
|
||||
|
||||
fun interface Reducer<S> {
|
||||
suspend fun reduce(action: Action): S
|
||||
}
|
||||
|
||||
private fun <S> createScope(coroutineScope: CoroutineScope, store: Store<S>) = object : ReducerScope<S> {
|
||||
override val coroutineScope = coroutineScope
|
||||
override suspend fun dispatch(action: Action) = store.dispatch(action)
|
||||
override fun getState(): S = store.getState()
|
||||
}
|
||||
|
||||
interface Store<S> {
|
||||
suspend fun dispatch(action: Action)
|
||||
fun getState(): S
|
||||
fun subscribe(subscriber: (S) -> Unit)
|
||||
}
|
||||
|
||||
interface ReducerScope<S> {
|
||||
val coroutineScope: CoroutineScope
|
||||
suspend fun dispatch(action: Action)
|
||||
fun getState(): S
|
||||
}
|
||||
|
||||
sealed interface ActionHandler<S> {
|
||||
val key: KClass<Action>
|
||||
|
||||
class Async<S>(override val key: KClass<Action>, val handler: suspend ReducerScope<S>.(Action) -> Unit) : ActionHandler<S>
|
||||
class Sync<S>(override val key: KClass<Action>, val handler: (Action, S) -> S) : ActionHandler<S>
|
||||
class Delegate<S>(override val key: KClass<Action>, val handler: ReducerScope<S>.(Action) -> ActionHandler<S>) : ActionHandler<S>
|
||||
}
|
||||
|
||||
fun <S> createReducer(
|
||||
initialState: S,
|
||||
vararg reducers: (ReducerScope<S>) -> ActionHandler<S>,
|
||||
): ReducerFactory<S> {
|
||||
return object : ReducerFactory<S> {
|
||||
override fun create(scope: ReducerScope<S>): Reducer<S> {
|
||||
val reducersMap = reducers
|
||||
.map { it.invoke(scope) }
|
||||
.groupBy { it.key }
|
||||
|
||||
return Reducer { action ->
|
||||
val result = reducersMap.keys
|
||||
.filter { it.java.isAssignableFrom(action::class.java) }
|
||||
.fold(scope.getState()) { acc, key ->
|
||||
val actionHandlers = reducersMap[key]!!
|
||||
actionHandlers.fold(acc) { acc, handler ->
|
||||
when (handler) {
|
||||
is ActionHandler.Async -> {
|
||||
handler.handler.invoke(scope, action)
|
||||
acc
|
||||
}
|
||||
|
||||
is ActionHandler.Sync -> handler.handler.invoke(action, acc)
|
||||
is ActionHandler.Delegate -> when (val next = handler.handler.invoke(scope, action)) {
|
||||
is ActionHandler.Async -> {
|
||||
next.handler.invoke(scope, action)
|
||||
acc
|
||||
}
|
||||
|
||||
is ActionHandler.Sync -> next.handler.invoke(action, acc)
|
||||
is ActionHandler.Delegate -> error("is not possible")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
override fun initialState(): S = initialState
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun <A : Action, S> sideEffect(klass: KClass<A>, block: suspend (A, S) -> Unit): (ReducerScope<S>) -> ActionHandler<S> {
|
||||
return {
|
||||
ActionHandler.Async(key = klass as KClass<Action>) { action -> block(action as A, getState()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun <A : Action, S> change(klass: KClass<A>, block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S> {
|
||||
return {
|
||||
ActionHandler.Sync(key = klass as KClass<Action>, block as (Action, S) -> S)
|
||||
}
|
||||
}
|
||||
|
||||
fun <A : Action, S> async(klass: KClass<A>, block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S> {
|
||||
return {
|
||||
ActionHandler.Async(key = klass as KClass<Action>, block as suspend ReducerScope<S>.(Action) -> Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun <A : Action, S> multi(klass: KClass<A>, block: Multi<A, S>.(A) -> (ReducerScope<S>) -> ActionHandler<S>): (ReducerScope<S>) -> ActionHandler<S> {
|
||||
val multiScope = object : Multi<A, S> {
|
||||
override fun sideEffect(block: (A, S) -> Unit): (ReducerScope<S>) -> ActionHandler<S> = sideEffect(klass, block)
|
||||
override fun change(block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S> = change(klass, block)
|
||||
override fun async(block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S> = async(klass, block)
|
||||
}
|
||||
|
||||
return {
|
||||
ActionHandler.Delegate(key = klass as KClass<Action>) { action ->
|
||||
block(multiScope, action as A).invoke(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Multi<A : Action, S> {
|
||||
fun sideEffect(block: (A, S) -> Unit): (ReducerScope<S>) -> ActionHandler<S>
|
||||
fun change(block: (A, S) -> S): (ReducerScope<S>) -> ActionHandler<S>
|
||||
fun async(block: suspend ReducerScope<S>.(A) -> Unit): (ReducerScope<S>) -> ActionHandler<S>
|
||||
}
|
||||
|
||||
interface Action
|
@ -0,0 +1,20 @@
|
||||
package fake
|
||||
|
||||
import org.amshove.kluent.internal.assertEquals
|
||||
|
||||
class FakeEventSource<E> : (E) -> Unit {
|
||||
|
||||
private val captures = mutableListOf<E>()
|
||||
|
||||
override fun invoke(event: E) {
|
||||
captures.add(event)
|
||||
}
|
||||
|
||||
fun assertEvents(expected: List<E>) {
|
||||
assertEquals(expected, captures)
|
||||
}
|
||||
|
||||
fun assertNoEvents() {
|
||||
assertEquals(emptyList(), captures)
|
||||
}
|
||||
}
|
126
domains/state/src/testFixtures/kotlin/test/ReducerTest.kt
Normal file
126
domains/state/src/testFixtures/kotlin/test/ReducerTest.kt
Normal file
@ -0,0 +1,126 @@
|
||||
package test
|
||||
|
||||
import app.dapk.state.Action
|
||||
import app.dapk.state.Reducer
|
||||
import app.dapk.state.ReducerFactory
|
||||
import app.dapk.state.ReducerScope
|
||||
import fake.FakeEventSource
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.amshove.kluent.internal.assertEquals
|
||||
import org.amshove.kluent.shouldBeEqualTo
|
||||
|
||||
interface ReducerTest<S, E> {
|
||||
operator fun invoke(block: suspend ReducerTestScope<S, E>.() -> Unit)
|
||||
}
|
||||
|
||||
fun <S, E> testReducer(block: ((E) -> Unit) -> ReducerFactory<S>): ReducerTest<S, E> {
|
||||
val fakeEventSource = FakeEventSource<E>()
|
||||
val reducerFactory = block(fakeEventSource)
|
||||
return object : ReducerTest<S, E> {
|
||||
override fun invoke(block: suspend ReducerTestScope<S, E>.() -> Unit) {
|
||||
runReducerTest(reducerFactory, fakeEventSource, block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun <S, E> runReducerTest(reducerFactory: ReducerFactory<S>, fakeEventSource: FakeEventSource<E>, block: suspend ReducerTestScope<S, E>.() -> Unit) {
|
||||
runTest {
|
||||
val expectTestScope = ExpectTest(coroutineContext)
|
||||
block(ReducerTestScope(reducerFactory, fakeEventSource, expectTestScope))
|
||||
expectTestScope.verifyExpects()
|
||||
}
|
||||
}
|
||||
|
||||
class ReducerTestScope<S, E>(
|
||||
private val reducerFactory: ReducerFactory<S>,
|
||||
private val fakeEventSource: FakeEventSource<E>,
|
||||
private val expectTestScope: ExpectTestScope
|
||||
) : ExpectTestScope by expectTestScope, Reducer<S> {
|
||||
|
||||
private var manualState: S? = null
|
||||
private var capturedResult: S? = null
|
||||
|
||||
private val actionCaptures = mutableListOf<Action>()
|
||||
private val reducerScope = object : ReducerScope<S> {
|
||||
override val coroutineScope = CoroutineScope(UnconfinedTestDispatcher())
|
||||
override suspend fun dispatch(action: Action) {
|
||||
actionCaptures.add(action)
|
||||
}
|
||||
|
||||
override fun getState() = manualState ?: reducerFactory.initialState()
|
||||
}
|
||||
private val reducer: Reducer<S> = reducerFactory.create(reducerScope)
|
||||
|
||||
override suspend fun reduce(action: Action) = reducer.reduce(action).also {
|
||||
capturedResult = it
|
||||
}
|
||||
|
||||
fun setState(state: S) {
|
||||
manualState = state
|
||||
}
|
||||
|
||||
fun setState(block: (S) -> S) {
|
||||
manualState = block(reducerScope.getState())
|
||||
}
|
||||
|
||||
fun assertInitialState(expected: S) {
|
||||
reducerFactory.initialState() shouldBeEqualTo expected
|
||||
}
|
||||
|
||||
fun assertEvents(events: List<E>) {
|
||||
fakeEventSource.assertEvents(events)
|
||||
}
|
||||
|
||||
fun assertOnlyStateChange(expected: S) {
|
||||
assertStateChange(expected)
|
||||
assertNoDispatches()
|
||||
fakeEventSource.assertNoEvents()
|
||||
}
|
||||
|
||||
fun assertOnlyStateChange(block: (S) -> S) {
|
||||
val expected = block(reducerScope.getState())
|
||||
assertStateChange(expected)
|
||||
assertNoDispatches()
|
||||
fakeEventSource.assertNoEvents()
|
||||
}
|
||||
|
||||
fun assertStateChange(expected: S) {
|
||||
capturedResult shouldBeEqualTo expected
|
||||
}
|
||||
|
||||
fun assertDispatches(expected: List<Action>) {
|
||||
assertEquals(expected, actionCaptures)
|
||||
}
|
||||
|
||||
fun assertNoDispatches() {
|
||||
assertEquals(emptyList(), actionCaptures)
|
||||
}
|
||||
|
||||
fun assertNoStateChange() {
|
||||
assertEquals(reducerScope.getState(), capturedResult)
|
||||
}
|
||||
|
||||
fun assertNoEvents() {
|
||||
fakeEventSource.assertNoEvents()
|
||||
}
|
||||
|
||||
fun assertOnlyDispatches(expected: List<Action>) {
|
||||
assertDispatches(expected)
|
||||
fakeEventSource.assertNoEvents()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
fun assertOnlyEvents(events: List<E>) {
|
||||
fakeEventSource.assertEvents(events)
|
||||
assertNoDispatches()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
fun assertNoChanges() {
|
||||
assertNoStateChange()
|
||||
assertNoEvents()
|
||||
assertNoDispatches()
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ dependencies {
|
||||
implementation project(":chat-engine")
|
||||
implementation project(":domains:android:compose-core")
|
||||
implementation project(":domains:android:viewmodel")
|
||||
implementation project(":domains:state")
|
||||
implementation project(":features:messenger")
|
||||
implementation project(":core")
|
||||
implementation project(":design-library")
|
||||
@ -13,6 +14,7 @@ dependencies {
|
||||
|
||||
androidImportFixturesWorkaround(project, project(":matrix:common"))
|
||||
androidImportFixturesWorkaround(project, project(":core"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:state"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:store"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:viewmodel"))
|
||||
androidImportFixturesWorkaround(project, project(":domains:android:stub"))
|
||||
|
@ -35,9 +35,13 @@ import app.dapk.st.design.components.CircleishAvatar
|
||||
import app.dapk.st.design.components.GenericEmpty
|
||||
import app.dapk.st.design.components.GenericError
|
||||
import app.dapk.st.design.components.Toolbar
|
||||
import app.dapk.st.directory.DirectoryEvent.OpenDownloadUrl
|
||||
import app.dapk.st.directory.DirectoryScreenState.Content
|
||||
import app.dapk.st.directory.DirectoryScreenState.EmptyLoading
|
||||
import app.dapk.st.directory.state.ComponentLifecycle
|
||||
import app.dapk.st.directory.state.DirectoryEvent
|
||||
import app.dapk.st.directory.state.DirectoryEvent.OpenDownloadUrl
|
||||
import app.dapk.st.directory.state.DirectoryScreenState
|
||||
import app.dapk.st.directory.state.DirectoryScreenState.Content
|
||||
import app.dapk.st.directory.state.DirectoryScreenState.EmptyLoading
|
||||
import app.dapk.st.directory.state.DirectoryState
|
||||
import app.dapk.st.engine.DirectoryItem
|
||||
import app.dapk.st.engine.RoomOverview
|
||||
import app.dapk.st.engine.Typing
|
||||
@ -53,8 +57,8 @@ import java.time.temporal.ChronoUnit
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun DirectoryScreen(directoryViewModel: DirectoryViewModel) {
|
||||
val state = directoryViewModel.state
|
||||
fun DirectoryScreen(directoryViewModel: DirectoryState) {
|
||||
val state = directoryViewModel.current
|
||||
|
||||
val listState: LazyListState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = 0,
|
||||
@ -68,8 +72,8 @@ fun DirectoryScreen(directoryViewModel: DirectoryViewModel) {
|
||||
directoryViewModel.ObserveEvents(listState, toolbarOffsetHeightPx)
|
||||
|
||||
LifecycleEffect(
|
||||
onStart = { directoryViewModel.start() },
|
||||
onStop = { directoryViewModel.stop() }
|
||||
onStart = { directoryViewModel.dispatch(ComponentLifecycle.OnVisible) },
|
||||
onStop = { directoryViewModel.dispatch(ComponentLifecycle.OnGone) }
|
||||
)
|
||||
|
||||
val nestedScrollConnection = remember {
|
||||
@ -101,7 +105,7 @@ fun DirectoryScreen(directoryViewModel: DirectoryViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DirectoryViewModel.ObserveEvents(listState: LazyListState, toolbarPosition: MutableState<Float>) {
|
||||
private fun DirectoryState.ObserveEvents(listState: LazyListState, toolbarPosition: MutableState<Float>) {
|
||||
val context = LocalContext.current
|
||||
StartObserving {
|
||||
this@ObserveEvents.events.launch {
|
||||
|
@ -2,6 +2,10 @@ package app.dapk.st.directory
|
||||
|
||||
import android.content.Context
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
import app.dapk.st.core.createStateViewModel
|
||||
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
|
||||
|
||||
class DirectoryModule(
|
||||
@ -9,10 +13,7 @@ class DirectoryModule(
|
||||
private val chatEngine: ChatEngine,
|
||||
) : ProvidableModule {
|
||||
|
||||
fun directoryViewModel(): DirectoryViewModel {
|
||||
return DirectoryViewModel(
|
||||
ShortcutHandler(context),
|
||||
chatEngine,
|
||||
)
|
||||
fun directoryState(): DirectoryState {
|
||||
return createStateViewModel { directoryReducer(chatEngine, ShortcutHandler(context), JobBag(), it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,45 +0,0 @@
|
||||
package app.dapk.st.directory
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.directory.DirectoryScreenState.*
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
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.collect
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DirectoryViewModel(
|
||||
private val shortcutHandler: ShortcutHandler,
|
||||
private val chatEngine: ChatEngine,
|
||||
factory: MutableStateFactory<DirectoryScreenState> = defaultStateFactory(),
|
||||
) : DapkViewModel<DirectoryScreenState, DirectoryEvent>(
|
||||
initialState = EmptyLoading,
|
||||
factory,
|
||||
) {
|
||||
|
||||
private var syncJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
syncJob = viewModelScope.launch {
|
||||
chatEngine.directory().onEach {
|
||||
shortcutHandler.onDirectoryUpdate(it.map { it.overview })
|
||||
state = when (it.isEmpty()) {
|
||||
true -> Empty
|
||||
false -> Content(it)
|
||||
}
|
||||
}.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
syncJob?.cancel()
|
||||
}
|
||||
|
||||
fun scrollToTopOfMessages() {
|
||||
_events.tryEmit(DirectoryEvent.ScrollToTop)
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import app.dapk.st.engine.RoomOverview
|
||||
import app.dapk.st.matrix.common.RoomId
|
||||
import app.dapk.st.messenger.MessengerActivity
|
||||
|
||||
class ShortcutHandler(private val context: Context) {
|
||||
internal class ShortcutHandler(private val context: Context) {
|
||||
|
||||
private val cachedRoomIds = mutableListOf<RoomId>()
|
||||
|
||||
|
@ -0,0 +1,18 @@
|
||||
package app.dapk.st.directory.state
|
||||
|
||||
import app.dapk.st.engine.DirectoryState
|
||||
import app.dapk.state.Action
|
||||
|
||||
sealed interface ComponentLifecycle : Action {
|
||||
object OnVisible : ComponentLifecycle
|
||||
object OnGone : ComponentLifecycle
|
||||
}
|
||||
|
||||
sealed interface DirectorySideEffect : Action {
|
||||
object ScrollToTop : DirectorySideEffect
|
||||
}
|
||||
|
||||
sealed interface DirectoryStateChange : Action {
|
||||
object Empty : DirectoryStateChange
|
||||
data class Content(val content: DirectoryState) : DirectoryStateChange
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
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.*
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
|
||||
private const val KEY_SYNCING_JOB = "sync"
|
||||
|
||||
internal fun directoryReducer(
|
||||
chatEngine: ChatEngine,
|
||||
shortcutHandler: ShortcutHandler,
|
||||
jobBag: JobBag,
|
||||
eventEmitter: suspend (DirectoryEvent) -> Unit,
|
||||
): ReducerFactory<DirectoryScreenState> {
|
||||
return createReducer(
|
||||
initialState = DirectoryScreenState.EmptyLoading,
|
||||
|
||||
multi(ComponentLifecycle::class) { action ->
|
||||
when (action) {
|
||||
ComponentLifecycle.OnVisible -> async { _ ->
|
||||
jobBag.add(KEY_SYNCING_JOB, chatEngine.directory().onEach {
|
||||
shortcutHandler.onDirectoryUpdate(it.map { it.overview })
|
||||
when (it.isEmpty()) {
|
||||
true -> dispatch(DirectoryStateChange.Empty)
|
||||
false -> dispatch(DirectoryStateChange.Content(it))
|
||||
}
|
||||
}.launchIn(coroutineScope))
|
||||
}
|
||||
|
||||
ComponentLifecycle.OnGone -> sideEffect { _, _ -> jobBag.cancel(KEY_SYNCING_JOB) }
|
||||
}
|
||||
},
|
||||
|
||||
change(DirectoryStateChange::class) { action, _ ->
|
||||
when (action) {
|
||||
is DirectoryStateChange.Content -> DirectoryScreenState.Content(action.content)
|
||||
DirectoryStateChange.Empty -> DirectoryScreenState.Empty
|
||||
}
|
||||
},
|
||||
|
||||
sideEffect(DirectorySideEffect.ScrollToTop::class) { _, _ ->
|
||||
eventEmitter(DirectoryEvent.ScrollToTop)
|
||||
}
|
||||
)
|
||||
}
|
@ -1,9 +1,11 @@
|
||||
package app.dapk.st.directory
|
||||
package app.dapk.st.directory.state
|
||||
|
||||
import app.dapk.st.core.State
|
||||
import app.dapk.st.engine.DirectoryState
|
||||
|
||||
sealed interface DirectoryScreenState {
|
||||
typealias DirectoryState = State<DirectoryScreenState, DirectoryEvent>
|
||||
|
||||
sealed interface DirectoryScreenState {
|
||||
object EmptyLoading : DirectoryScreenState
|
||||
object Empty : DirectoryScreenState
|
||||
data class Content(
|
||||
@ -15,4 +17,3 @@ sealed interface DirectoryEvent {
|
||||
data class OpenDownloadUrl(val url: String) : DirectoryEvent
|
||||
object ScrollToTop : DirectoryEvent
|
||||
}
|
||||
|
@ -0,0 +1,98 @@
|
||||
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 fake.FakeChatEngine
|
||||
import fixture.aRoomOverview
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
import test.expect
|
||||
import test.testReducer
|
||||
|
||||
private val AN_OVERVIEW = aRoomOverview()
|
||||
private val AN_OVERVIEW_STATE = DirectoryItem(AN_OVERVIEW, UnreadCount(1), null)
|
||||
|
||||
class DirectoryReducerTest {
|
||||
|
||||
private val fakeShortcutHandler = FakeShortcutHandler()
|
||||
private val fakeChatEngine = FakeChatEngine()
|
||||
private val fakeJobBag = FakeJobBag()
|
||||
|
||||
private val runReducerTest = testReducer { fakeEventSource ->
|
||||
directoryReducer(
|
||||
fakeChatEngine,
|
||||
fakeShortcutHandler.instance,
|
||||
fakeJobBag.instance,
|
||||
fakeEventSource,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state is empty loading`() = runReducerTest {
|
||||
assertInitialState(DirectoryScreenState.EmptyLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given directory content, when Visible, then updates shortcuts and dispatches room state`() = runReducerTest {
|
||||
fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(listOf(AN_OVERVIEW)) }
|
||||
fakeJobBag.instance.expect { it.add("sync", any()) }
|
||||
fakeChatEngine.givenDirectory().returns(flowOf(listOf(AN_OVERVIEW_STATE)))
|
||||
|
||||
reduce(ComponentLifecycle.OnVisible)
|
||||
|
||||
assertOnlyDispatches(listOf(DirectoryStateChange.Content(listOf(AN_OVERVIEW_STATE))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given no directory content, when Visible, then updates shortcuts and dispatches empty state`() = runReducerTest {
|
||||
fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(emptyList()) }
|
||||
fakeJobBag.instance.expect { it.add("sync", any()) }
|
||||
fakeChatEngine.givenDirectory().returns(flowOf(emptyList()))
|
||||
|
||||
reduce(ComponentLifecycle.OnVisible)
|
||||
|
||||
assertOnlyDispatches(listOf(DirectoryStateChange.Empty))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Gone, then cancels sync job`() = runReducerTest {
|
||||
fakeJobBag.instance.expect { it.cancel("sync") }
|
||||
|
||||
reduce(ComponentLifecycle.OnGone)
|
||||
|
||||
assertNoChanges()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when ScrollToTop, then emits Scroll event`() = runReducerTest {
|
||||
reduce(DirectorySideEffect.ScrollToTop)
|
||||
|
||||
assertOnlyEvents(listOf(DirectoryEvent.ScrollToTop))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Content StateChange, then returns Content state`() = runReducerTest {
|
||||
reduce(DirectoryStateChange.Content(listOf(AN_OVERVIEW_STATE)))
|
||||
|
||||
assertOnlyStateChange(DirectoryScreenState.Content(listOf(AN_OVERVIEW_STATE)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Empty StateChange, then returns Empty state`() = runReducerTest {
|
||||
reduce(DirectoryStateChange.Empty)
|
||||
|
||||
assertOnlyStateChange(DirectoryScreenState.Empty)
|
||||
}
|
||||
}
|
||||
|
||||
internal class FakeShortcutHandler {
|
||||
val instance = mockk<ShortcutHandler>()
|
||||
}
|
||||
|
||||
class FakeJobBag {
|
||||
val instance = mockk<JobBag>()
|
||||
}
|
||||
|
@ -1,48 +0,0 @@
|
||||
package app.dapk.st.directory
|
||||
|
||||
import ViewModelTest
|
||||
import app.dapk.st.engine.DirectoryItem
|
||||
import app.dapk.st.engine.UnreadCount
|
||||
import fake.FakeChatEngine
|
||||
import fixture.aRoomOverview
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
|
||||
private val AN_OVERVIEW = aRoomOverview()
|
||||
private val AN_OVERVIEW_STATE = DirectoryItem(AN_OVERVIEW, UnreadCount(1), null)
|
||||
|
||||
class DirectoryViewModelTest {
|
||||
|
||||
private val runViewModelTest = ViewModelTest()
|
||||
private val fakeShortcutHandler = FakeShortcutHandler()
|
||||
private val fakeChatEngine = FakeChatEngine()
|
||||
|
||||
private val viewModel = DirectoryViewModel(
|
||||
fakeShortcutHandler.instance,
|
||||
fakeChatEngine,
|
||||
runViewModelTest.testMutableStateFactory(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when creating view model, then initial state is empty loading`() = runViewModelTest {
|
||||
viewModel.test()
|
||||
|
||||
assertInitialState(DirectoryScreenState.EmptyLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when starting, then updates shortcuts and emits room state`() = runViewModelTest {
|
||||
fakeShortcutHandler.instance.expectUnit { it.onDirectoryUpdate(listOf(AN_OVERVIEW)) }
|
||||
fakeChatEngine.givenDirectory().returns(flowOf(listOf(AN_OVERVIEW_STATE)))
|
||||
|
||||
viewModel.test().start()
|
||||
|
||||
assertStates(DirectoryScreenState.Content(listOf(AN_OVERVIEW_STATE)))
|
||||
verifyExpects()
|
||||
}
|
||||
}
|
||||
|
||||
class FakeShortcutHandler {
|
||||
val instance = mockk<ShortcutHandler>()
|
||||
}
|
@ -9,6 +9,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(":design-library")
|
||||
implementation Dependencies.mavenCentral.coil
|
||||
|
@ -1,7 +1,7 @@
|
||||
package app.dapk.st.home
|
||||
|
||||
import app.dapk.st.core.ProvidableModule
|
||||
import app.dapk.st.directory.DirectoryViewModel
|
||||
import app.dapk.st.directory.state.DirectoryState
|
||||
import app.dapk.st.domain.StoreModule
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.login.LoginViewModel
|
||||
@ -13,7 +13,7 @@ class HomeModule(
|
||||
val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
|
||||
) : ProvidableModule {
|
||||
|
||||
fun homeViewModel(directory: DirectoryViewModel, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel {
|
||||
internal fun homeViewModel(directory: DirectoryState, login: LoginViewModel, profileViewModel: ProfileViewModel): HomeViewModel {
|
||||
return HomeViewModel(
|
||||
chatEngine,
|
||||
storeModule.credentialsStore(),
|
||||
|
@ -18,7 +18,7 @@ import app.dapk.st.profile.ProfileScreen
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HomeScreen(homeViewModel: HomeViewModel) {
|
||||
internal fun HomeScreen(homeViewModel: HomeViewModel) {
|
||||
LifecycleEffect(
|
||||
onStart = { homeViewModel.start() },
|
||||
onStop = { homeViewModel.stop() }
|
||||
|
@ -1,7 +1,8 @@
|
||||
package app.dapk.st.home
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.dapk.st.directory.DirectoryViewModel
|
||||
import app.dapk.st.directory.state.DirectorySideEffect
|
||||
import app.dapk.st.directory.state.DirectoryState
|
||||
import app.dapk.st.domain.StoreCleaner
|
||||
import app.dapk.st.engine.ChatEngine
|
||||
import app.dapk.st.home.HomeScreenState.*
|
||||
@ -17,10 +18,10 @@ import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class HomeViewModel(
|
||||
internal class HomeViewModel(
|
||||
private val chatEngine: ChatEngine,
|
||||
private val credentialsProvider: CredentialsStore,
|
||||
private val directoryViewModel: DirectoryViewModel,
|
||||
private val directoryState: DirectoryState,
|
||||
private val loginViewModel: LoginViewModel,
|
||||
private val profileViewModel: ProfileViewModel,
|
||||
private val cacheCleaner: StoreCleaner,
|
||||
@ -31,7 +32,7 @@ class HomeViewModel(
|
||||
|
||||
private var listenForInvitesJob: Job? = null
|
||||
|
||||
fun directory() = directoryViewModel
|
||||
fun directory() = directoryState
|
||||
fun login() = loginViewModel
|
||||
fun profile() = profileViewModel
|
||||
|
||||
@ -92,7 +93,7 @@ class HomeViewModel(
|
||||
}
|
||||
|
||||
fun scrollToTopOfMessages() {
|
||||
directoryViewModel.scrollToTopOfMessages()
|
||||
directoryState.dispatch(DirectorySideEffect.ScrollToTop)
|
||||
}
|
||||
|
||||
fun changePage(page: Page) {
|
||||
|
@ -13,6 +13,7 @@ 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.state
|
||||
import app.dapk.st.core.viewModel
|
||||
import app.dapk.st.directory.DirectoryModule
|
||||
import app.dapk.st.login.LoginModule
|
||||
@ -22,7 +23,7 @@ import kotlinx.coroutines.flow.onEach
|
||||
|
||||
class MainActivity : DapkActivity() {
|
||||
|
||||
private val directoryViewModel by viewModel { module<DirectoryModule>().directoryViewModel() }
|
||||
private val directoryViewModel by state { module<DirectoryModule>().directoryState() }
|
||||
private val loginViewModel by viewModel { module<LoginModule>().loginViewModel() }
|
||||
private val profileViewModel by viewModel { module<ProfileModule>().profileViewModel() }
|
||||
private val homeViewModel by viewModel { module<HomeModule>().homeViewModel(directoryViewModel, loginViewModel, profileViewModel) }
|
||||
|
@ -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"))
|
||||
|
@ -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<ImageRequest.Builder> {
|
||||
class MessengerActivity : DapkActivity() {
|
||||
|
||||
private val module by unsafeLazy { module<MessengerModule>() }
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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<MessageAttachment>?,
|
||||
viewModel: MessengerViewModel,
|
||||
viewModel: MessengerState,
|
||||
navigator: Navigator,
|
||||
galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>
|
||||
) {
|
||||
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<ImageGalleryActivityPayload>) {
|
||||
private fun MessengerState.ObserveEvents(galleryLauncher: ActivityResultLauncher<ImageGalleryActivityPayload>) {
|
||||
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<MessengerState>, messageActions: MessageActions, onRetry: () -> Unit) {
|
||||
private fun ColumnScope.Room(roomStateLce: Lce<MessengerPageState>, messageActions: MessageActions, onRetry: () -> Unit) {
|
||||
when (val state = roomStateLce) {
|
||||
is Lce.Loading -> CenteredLoading()
|
||||
is Lce.Content -> {
|
||||
|
@ -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<MessengerScreenState> = defaultStateFactory(),
|
||||
) : DapkViewModel<MessengerScreenState, MessengerEvent>(
|
||||
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<MessageAttachment>?) : MessengerAction
|
||||
object OnMessengerGone : MessengerAction
|
||||
}
|
@ -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<MessengerModule>().messengerViewModel() }
|
||||
|
||||
companion object {
|
||||
fun newInstance(context: Context, roomId: RoomId): Intent {
|
||||
return Intent(context, RoomSettingsActivity::class.java).apply {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
@ -0,0 +1,177 @@
|
||||
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.MessengerPageState
|
||||
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<MessageAttachment>?,
|
||||
eventEmitter: suspend (MessengerEvent) -> Unit,
|
||||
): ReducerFactory<MessengerScreenState> {
|
||||
return createReducer(
|
||||
initialState = MessengerScreenState(
|
||||
roomId = roomId,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = initialComposerState(initialAttachments),
|
||||
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 ->
|
||||
chatEngine.sendTextMessage(content, composerState)
|
||||
}
|
||||
}
|
||||
|
||||
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 suspend fun ChatEngine.sendTextMessage(content: MessengerPageState, composerState: ComposerState.Text) {
|
||||
val roomState = content.roomState
|
||||
val message = SendMessage.TextMessage(
|
||||
content = composerState.value,
|
||||
reply = composerState.reply?.toSendMessageReply(),
|
||||
)
|
||||
this.send(message = message, room = roomState.roomOverview)
|
||||
}
|
||||
|
||||
private fun RoomEvent.toSendMessageReply() = SendMessage.TextMessage.Reply(
|
||||
author = this.author,
|
||||
originalMessage = when (this) {
|
||||
is RoomEvent.Image -> TODO()
|
||||
is RoomEvent.Reply -> TODO()
|
||||
is RoomEvent.Message -> this.content.asString()
|
||||
is RoomEvent.Encrypted -> error("Should never happen")
|
||||
},
|
||||
eventId = this.eventId,
|
||||
timestampUtc = this.utcTimestamp,
|
||||
)
|
||||
|
||||
|
||||
private fun initialComposerState(initialAttachments: List<MessageAttachment>?) = initialAttachments
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { ComposerState.Attachments(it, null) }
|
||||
?: ComposerState.Text(value = "", reply = 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
|
||||
}
|
@ -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<MessengerScreenState, MessengerEvent>
|
||||
|
||||
data class MessengerScreenState(
|
||||
val roomId: RoomId?,
|
||||
val roomState: Lce<MessengerState>,
|
||||
val roomId: RoomId,
|
||||
val roomState: Lce<MessengerPageState>,
|
||||
val composerState: ComposerState,
|
||||
val viewerState: ViewerState?
|
||||
)
|
@ -0,0 +1,362 @@
|
||||
package app.dapk.st.messenger
|
||||
|
||||
import android.os.Build
|
||||
import app.dapk.st.core.*
|
||||
import app.dapk.st.design.components.BubbleModel
|
||||
import app.dapk.st.engine.RoomEvent
|
||||
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.UserId
|
||||
import app.dapk.st.matrix.common.asString
|
||||
import app.dapk.st.messenger.state.*
|
||||
import app.dapk.st.navigator.MessageAttachment
|
||||
import fake.FakeChatEngine
|
||||
import fake.FakeMessageOptionsStore
|
||||
import fixture.*
|
||||
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
|
||||
|
||||
private const val READ_RECEIPTS_ARE_DISABLED = true
|
||||
private val A_ROOM_ID = aRoomId("messenger state room id")
|
||||
private const val A_MESSAGE_CONTENT = "message content"
|
||||
private val AN_EVENT_ID = anEventId("state event")
|
||||
private val A_SELF_ID = aUserId("self")
|
||||
private val A_MESSENGER_PAGE_STATE = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
|
||||
private val A_MESSAGE_ATTACHMENT = MessageAttachment(AndroidUri("a-uri"), MimeType.Image)
|
||||
private val A_REPLY = aRoomReplyMessageEvent()
|
||||
private val AN_IMAGE_BUBBLE = BubbleModel.Image(
|
||||
BubbleModel.Image.ImageContent(100, 200, "a-url"),
|
||||
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")
|
||||
)
|
||||
|
||||
class MessengerReducerTest {
|
||||
|
||||
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
|
||||
private val fakeChatEngine = FakeChatEngine()
|
||||
private val fakeCopyToClipboard = FakeCopyToClipboard()
|
||||
private val fakeDeviceMeta = FakeDeviceMeta()
|
||||
private val fakeJobBag = FakeJobBag()
|
||||
|
||||
private val runReducerTest = testReducer { fakeEventSource ->
|
||||
messengerReducer(
|
||||
fakeJobBag.instance,
|
||||
fakeChatEngine,
|
||||
fakeCopyToClipboard.instance,
|
||||
fakeDeviceMeta.instance,
|
||||
fakeMessageOptionsStore.instance,
|
||||
A_ROOM_ID,
|
||||
emptyList(),
|
||||
fakeEventSource,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given empty initial attachments, then initial state is loading with text composer`() = reducerWithInitialState(initialAttachments = emptyList()) {
|
||||
assertInitialState(
|
||||
MessengerScreenState(
|
||||
roomId = A_ROOM_ID,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = ComposerState.Text(value = "", reply = null),
|
||||
viewerState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given null initial attachments, then initial state is loading with text composer`() = reducerWithInitialState(initialAttachments = null) {
|
||||
assertInitialState(
|
||||
MessengerScreenState(
|
||||
roomId = A_ROOM_ID,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = ComposerState.Text(value = "", reply = null),
|
||||
viewerState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given initial attachments, then initial state is loading attachment composer`() = reducerWithInitialState(listOf(A_MESSAGE_ATTACHMENT)) {
|
||||
assertInitialState(
|
||||
MessengerScreenState(
|
||||
roomId = A_ROOM_ID,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null),
|
||||
viewerState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given messages emits state, when Visible, then dispatches content`() = runReducerTest {
|
||||
fakeJobBag.instance.expect { it.add("messages", any()) }
|
||||
fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(READ_RECEIPTS_ARE_DISABLED)
|
||||
val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
|
||||
fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state))
|
||||
|
||||
reduce(ComponentLifecycle.Visible)
|
||||
|
||||
assertOnlyDispatches(listOf(MessagesStateChange.Content(state)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Gone, then cancels sync job`() = runReducerTest {
|
||||
fakeJobBag.instance.expect { it.cancel("messages") }
|
||||
|
||||
reduce(ComponentLifecycle.Gone)
|
||||
|
||||
assertNoChanges()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Content StateChange, then updates room state`() = runReducerTest {
|
||||
reduce(MessagesStateChange.Content(A_MESSENGER_PAGE_STATE))
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(roomState = Lce.Content(A_MESSENGER_PAGE_STATE))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when SelectAttachmentToSend, then updates composer state`() = runReducerTest {
|
||||
reduce(ComposerStateChange.SelectAttachmentToSend(A_MESSAGE_ATTACHMENT))
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Show ImagePreview, then updates viewer state`() = runReducerTest {
|
||||
reduce(ComposerStateChange.ImagePreview.Show(AN_IMAGE_BUBBLE))
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(viewerState = ViewerState(AN_IMAGE_BUBBLE))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Hide ImagePreview, then updates viewer state`() = runReducerTest {
|
||||
reduce(ComposerStateChange.ImagePreview.Hide)
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(viewerState = null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when TextUpdate StateChange, then updates composer state`() = runReducerTest {
|
||||
reduce(ComposerStateChange.TextUpdate(A_MESSAGE_CONTENT))
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when Clear ComposerStateChange, then clear composer state`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null)) }
|
||||
|
||||
reduce(ComposerStateChange.Clear)
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = ComposerState.Text(value = "", reply = null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given text composer, when Enter ReplyMode, then updates composer state with reply`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null)) }
|
||||
|
||||
reduce(ComposerStateChange.ReplyMode.Enter(A_REPLY))
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = (previous.composerState as ComposerState.Text).copy(reply = A_REPLY))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given text composer, when Exit ReplyMode, then updates composer state`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = A_REPLY)) }
|
||||
|
||||
reduce(ComposerStateChange.ReplyMode.Exit)
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = (previous.composerState as ComposerState.Text).copy(reply = null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given attachment composer, when Enter ReplyMode, then updates composer state with reply`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null)) }
|
||||
|
||||
reduce(ComposerStateChange.ReplyMode.Enter(A_REPLY))
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = (previous.composerState as ComposerState.Attachments).copy(reply = A_REPLY))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given attachment composer, when Exit ReplyMode, then updates composer state`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = A_REPLY)) }
|
||||
|
||||
reduce(ComposerStateChange.ReplyMode.Exit)
|
||||
|
||||
assertOnlyStateChange { previous ->
|
||||
previous.copy(composerState = (previous.composerState as ComposerState.Attachments).copy(reply = null))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when OpenGalleryPicker, then emits event`() = runReducerTest {
|
||||
reduce(ScreenAction.OpenGalleryPicker)
|
||||
|
||||
assertOnlyEvents(listOf(MessengerEvent.SelectImageAttachment))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given android api is lower than S_v2 and has text content, when CopyToClipboard, then copies to system and toasts`() = runReducerTest {
|
||||
fakeDeviceMeta.givenApiVersion().returns(Build.VERSION_CODES.S)
|
||||
fakeCopyToClipboard.instance.expect { it.copy(CopyToClipboard.Copyable.Text(A_MESSAGE_CONTENT)) }
|
||||
|
||||
reduce(ScreenAction.CopyToClipboard(A_TEXT_BUBBLE))
|
||||
|
||||
assertEvents(listOf(MessengerEvent.Toast("Copied to clipboard")))
|
||||
assertNoDispatches()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given android api is higher than S_v2 and has text content, when CopyToClipboard, then copies to system and does not toast`() = runReducerTest {
|
||||
fakeDeviceMeta.givenApiVersion().returns(Build.VERSION_CODES.TIRAMISU)
|
||||
fakeCopyToClipboard.instance.expect { it.copy(CopyToClipboard.Copyable.Text(A_MESSAGE_CONTENT)) }
|
||||
|
||||
reduce(ScreenAction.CopyToClipboard(A_TEXT_BUBBLE))
|
||||
|
||||
assertNoChanges()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given image content, when CopyToClipboard, then toasts nothing to copy`() = runReducerTest {
|
||||
reduce(ScreenAction.CopyToClipboard(AN_IMAGE_BUBBLE))
|
||||
|
||||
assertEvents(listOf(MessengerEvent.Toast("Nothing to copy")))
|
||||
assertNoDispatches()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given text composer, when SendMessage, then clear composer and sends text message`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null), roomState = Lce.Content(A_MESSENGER_PAGE_STATE)) }
|
||||
fakeChatEngine.expectUnit { it.send(expectTextMessage(A_MESSAGE_CONTENT), A_MESSENGER_PAGE_STATE.roomState.roomOverview) }
|
||||
|
||||
reduce(ScreenAction.SendMessage)
|
||||
|
||||
assertDispatches(listOf(ComposerStateChange.Clear))
|
||||
assertNoEvents()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun `given text composer with reply, when SendMessage, then clear composer and sends text message`() = runReducerTest {
|
||||
setState { it.copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = A_REPLY.message), roomState = Lce.Content(A_MESSENGER_PAGE_STATE)) }
|
||||
fakeChatEngine.expectUnit { it.send(expectTextMessage(A_MESSAGE_CONTENT, reply = A_REPLY.message), A_MESSENGER_PAGE_STATE.roomState.roomOverview) }
|
||||
|
||||
reduce(ScreenAction.SendMessage)
|
||||
|
||||
assertDispatches(listOf(ComposerStateChange.Clear))
|
||||
assertNoEvents()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given attachment composer, when SendMessage, then clear composer and sends image message`() = runReducerTest {
|
||||
setState {
|
||||
it.copy(
|
||||
composerState = ComposerState.Attachments(listOf(A_MESSAGE_ATTACHMENT), reply = null),
|
||||
roomState = Lce.Content(A_MESSENGER_PAGE_STATE)
|
||||
)
|
||||
}
|
||||
fakeChatEngine.expectUnit { it.send(expectImageMessage(A_MESSAGE_ATTACHMENT.uri), A_MESSENGER_PAGE_STATE.roomState.roomOverview) }
|
||||
|
||||
reduce(ScreenAction.SendMessage)
|
||||
|
||||
assertDispatches(listOf(ComposerStateChange.Clear))
|
||||
assertNoEvents()
|
||||
assertNoStateChange()
|
||||
}
|
||||
|
||||
private fun expectTextMessage(messageContent: String, reply: RoomEvent? = null): SendMessage.TextMessage {
|
||||
return SendMessage.TextMessage(messageContent, reply = reply?.toSendMessageReply())
|
||||
}
|
||||
|
||||
private fun expectImageMessage(uri: AndroidUri): SendMessage.ImageMessage {
|
||||
return SendMessage.ImageMessage(uri.value)
|
||||
}
|
||||
|
||||
private fun RoomEvent.toSendMessageReply() = SendMessage.TextMessage.Reply(
|
||||
author = this.author,
|
||||
originalMessage = when (this) {
|
||||
is RoomEvent.Image -> TODO()
|
||||
is RoomEvent.Reply -> TODO()
|
||||
is RoomEvent.Message -> this.content.asString()
|
||||
is RoomEvent.Encrypted -> error("Should never happen")
|
||||
},
|
||||
eventId = this.eventId,
|
||||
timestampUtc = this.utcTimestamp,
|
||||
)
|
||||
|
||||
private fun reducerWithInitialState(
|
||||
initialAttachments: List<MessageAttachment>?,
|
||||
block: suspend ReducerTestScope<MessengerScreenState, MessengerEvent>.() -> Unit
|
||||
) = testReducer { fakeEventSource ->
|
||||
messengerReducer(
|
||||
fakeJobBag.instance,
|
||||
fakeChatEngine,
|
||||
fakeCopyToClipboard.instance,
|
||||
fakeDeviceMeta.instance,
|
||||
fakeMessageOptionsStore.instance,
|
||||
A_ROOM_ID,
|
||||
initialAttachments = initialAttachments,
|
||||
fakeEventSource,
|
||||
)
|
||||
}(block)
|
||||
|
||||
}
|
||||
|
||||
private fun aMessengerStateWithEvent(eventId: EventId, selfId: UserId) = aRoomStateWithEventId(eventId).toMessengerState(selfId)
|
||||
|
||||
private fun aRoomStateWithEventId(eventId: EventId): RoomState {
|
||||
val element = anEncryptedRoomMessageEvent(eventId = eventId, utcTimestamp = 1)
|
||||
return RoomState(aRoomOverview(roomId = A_ROOM_ID, isEncrypted = true), listOf(element))
|
||||
}
|
||||
|
||||
private fun RoomState.toMessengerState(selfId: UserId) = aMessengerState(self = selfId, roomState = this)
|
||||
|
||||
class FakeCopyToClipboard {
|
||||
val instance = mockk<CopyToClipboard>()
|
||||
}
|
||||
|
||||
class FakeJobBag {
|
||||
val instance = mockk<JobBag>()
|
||||
}
|
||||
|
||||
class FakeDeviceMeta {
|
||||
val instance = mockk<DeviceMeta>()
|
||||
|
||||
fun givenApiVersion() = every { instance.apiVersion }.delegateReturn()
|
||||
}
|
@ -1,124 +0,0 @@
|
||||
package app.dapk.st.messenger
|
||||
|
||||
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.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 fake.FakeChatEngine
|
||||
import fake.FakeMessageOptionsStore
|
||||
import fixture.*
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import org.junit.Test
|
||||
|
||||
private const val READ_RECEIPTS_ARE_DISABLED = true
|
||||
private val A_ROOM_ID = aRoomId("messenger state room id")
|
||||
private const val A_MESSAGE_CONTENT = "message content"
|
||||
private val AN_EVENT_ID = anEventId("state event")
|
||||
private val A_SELF_ID = aUserId("self")
|
||||
|
||||
class MessengerViewModelTest {
|
||||
|
||||
private val runViewModelTest = ViewModelTest()
|
||||
|
||||
private val fakeMessageOptionsStore = FakeMessageOptionsStore()
|
||||
private val fakeChatEngine = FakeChatEngine()
|
||||
private val fakeCopyToClipboard = FakeCopyToClipboard()
|
||||
private val deviceMeta = DeviceMeta(26)
|
||||
|
||||
private val viewModel = MessengerViewModel(
|
||||
fakeChatEngine,
|
||||
fakeMessageOptionsStore.instance,
|
||||
fakeCopyToClipboard.instance,
|
||||
deviceMeta,
|
||||
factory = runViewModelTest.testMutableStateFactory(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when creating view model, then initial state is loading room state`() = runViewModelTest {
|
||||
viewModel.test()
|
||||
|
||||
assertInitialState(
|
||||
MessengerScreenState(
|
||||
roomId = null,
|
||||
roomState = Lce.Loading(),
|
||||
composerState = ComposerState.Text(value = "", reply = null),
|
||||
viewerState = null,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given timeline emits state, when starting, then updates state and marks room and events as read`() = runViewModelTest {
|
||||
fakeMessageOptionsStore.givenReadReceiptsDisabled().returns(READ_RECEIPTS_ARE_DISABLED)
|
||||
val state = aMessengerStateWithEvent(AN_EVENT_ID, A_SELF_ID)
|
||||
fakeChatEngine.givenMessages(A_ROOM_ID, READ_RECEIPTS_ARE_DISABLED).returns(flowOf(state))
|
||||
|
||||
viewModel.test().post(MessengerAction.OnMessengerVisible(A_ROOM_ID, attachments = null))
|
||||
|
||||
assertStates<MessengerScreenState>(
|
||||
{ copy(roomId = A_ROOM_ID) },
|
||||
{ copy(roomState = Lce.Content(state)) },
|
||||
)
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when posting composer update, then updates state`() = runViewModelTest {
|
||||
viewModel.test().post(MessengerAction.ComposerTextUpdate(A_MESSAGE_CONTENT))
|
||||
|
||||
assertStates<MessengerScreenState>({
|
||||
copy(composerState = ComposerState.Text(A_MESSAGE_CONTENT, reply = null))
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given composer message state when posting send text, then resets composer state and sends message`() = runViewModelTest {
|
||||
val initialState = initialStateWithComposerMessage(A_ROOM_ID, A_MESSAGE_CONTENT)
|
||||
fakeChatEngine.expectUnit { it.send(expectTextMessage(A_MESSAGE_CONTENT), initialState.roomState.takeIfContent()!!.roomState.roomOverview) }
|
||||
|
||||
viewModel.test(initialState = initialState).post(MessengerAction.ComposerSendText)
|
||||
|
||||
assertStates<MessengerScreenState>({ copy(composerState = ComposerState.Text("", reply = null)) })
|
||||
verifyExpects()
|
||||
}
|
||||
|
||||
private fun initialStateWithComposerMessage(roomId: RoomId, messageContent: String): MessengerScreenState {
|
||||
val roomState = RoomState(
|
||||
aRoomOverview(roomId = roomId, isEncrypted = true),
|
||||
listOf(anEncryptedRoomMessageEvent(utcTimestamp = 1))
|
||||
)
|
||||
return aMessageScreenState(roomId, aMessengerState(roomState = roomState), messageContent)
|
||||
}
|
||||
|
||||
private fun expectTextMessage(messageContent: String): SendMessage.TextMessage {
|
||||
return SendMessage.TextMessage(messageContent, reply = null)
|
||||
}
|
||||
|
||||
private fun aMessengerStateWithEvent(eventId: EventId, selfId: UserId) = aRoomStateWithEventId(eventId).toMessengerState(selfId)
|
||||
|
||||
private fun RoomState.toMessengerState(selfId: UserId) = aMessengerState(self = selfId, roomState = this)
|
||||
|
||||
private fun aRoomStateWithEventId(eventId: EventId): RoomState {
|
||||
val element = anEncryptedRoomMessageEvent(eventId = eventId, utcTimestamp = 1)
|
||||
return RoomState(aRoomOverview(roomId = A_ROOM_ID, isEncrypted = true), listOf(element))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun aMessageScreenState(roomId: RoomId = aRoomId(), roomState: MessengerState, messageContent: String?) = MessengerScreenState(
|
||||
roomId = roomId,
|
||||
roomState = Lce.Content(roomState),
|
||||
composerState = ComposerState.Text(value = messageContent ?: "", reply = null),
|
||||
viewerState = null,
|
||||
)
|
||||
|
||||
class FakeCopyToClipboard {
|
||||
val instance = mockk<CopyToClipboard>()
|
||||
}
|
@ -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<MessengerState> {
|
||||
override fun messages(roomId: RoomId, disableReadReceipts: Boolean): Flow<MessengerPageState> {
|
||||
return timelineUseCase.value.fetch(roomId, isReadReceiptsDisabled = disableReadReceipts)
|
||||
}
|
||||
|
||||
|
@ -19,7 +19,7 @@ class ReadMarkingTimeline(
|
||||
private val roomService: RoomService,
|
||||
) {
|
||||
|
||||
fun fetch(roomId: RoomId, isReadReceiptsDisabled: Boolean): Flow<MessengerState> {
|
||||
fun fetch(roomId: RoomId, isReadReceiptsDisabled: Boolean): Flow<MessengerPageState> {
|
||||
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<RoomEvent.Message>()
|
||||
.filterNot { it.author.id == self }
|
||||
.firstOrNull()
|
||||
|
@ -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<MessengerState>
|
||||
internal typealias ObserveTimelineUseCase = (RoomId, UserId) -> Flow<MessengerPageState>
|
||||
|
||||
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<MessengerState> {
|
||||
override fun invoke(roomId: RoomId, userId: UserId): Flow<MessengerPageState> {
|
||||
return combine(
|
||||
roomDatasource(roomId),
|
||||
messageService.localEchos(roomId),
|
||||
syncService.events(roomId)
|
||||
) { roomState, localEchos, events ->
|
||||
MessengerState(
|
||||
MessengerPageState(
|
||||
roomState = when {
|
||||
localEchos.isEmpty() -> roomState
|
||||
else -> {
|
||||
|
@ -135,4 +135,4 @@ fun aMessengerState(
|
||||
self: UserId = aUserId(),
|
||||
roomState: app.dapk.st.engine.RoomState,
|
||||
typing: Typing? = null
|
||||
) = MessengerState(self, roomState, typing)
|
||||
) = MessengerPageState(self, roomState, typing)
|
@ -33,6 +33,7 @@ include ':domains:android:viewmodel'
|
||||
include ':domains:store'
|
||||
include ':domains:olm-stub'
|
||||
include ':domains:olm'
|
||||
include ':domains:state'
|
||||
|
||||
include ':domains:firebase:crashlytics'
|
||||
include ':domains:firebase:crashlytics-noop'
|
||||
|
Loading…
x
Reference in New Issue
Block a user