Merge pull request #233 from ouchadam/tech/redux-pattern

Tech/redux pattern
This commit is contained in:
Adam Brown 2022-11-01 12:26:37 +00:00 committed by GitHub
commit 72fa795d38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1276 additions and 497 deletions

View File

@ -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>

View File

@ -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?

View File

@ -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(),

View 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()
}
}

View File

@ -5,4 +5,5 @@ dependencies {
implementation project(":features:navigator")
implementation project(":design-library")
api project(":domains:android:core")
api project(":domains:state")
}

View File

@ -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
}

View File

@ -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
}

View File

@ -25,4 +25,3 @@ abstract class DapkViewModel<S, VE>(initialState: S, factory: MutableStateFactor
state = reducer(state)
}
}

View 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
}

View 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

View File

@ -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)
}
}

View 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()
}
}

View File

@ -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"))

View File

@ -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 {

View File

@ -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) }
}
}
}

View File

@ -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)
}
}

View File

@ -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>()

View File

@ -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
}

View File

@ -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)
}
)
}

View File

@ -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
}

View File

@ -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>()
}

View File

@ -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>()
}

View File

@ -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

View File

@ -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(),

View File

@ -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() }

View File

@ -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) {

View File

@ -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) }

View File

@ -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"))

View File

@ -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)
}
}
}

View File

@ -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())

View File

@ -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 -> {

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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?
)

View File

@ -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()
}

View File

@ -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>()
}

View File

@ -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)
}

View File

@ -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()

View File

@ -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 -> {

View File

@ -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)

View File

@ -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'