From 1509d82f3fa621b4bde066d583cf4e361f24e825 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 3 Jan 2023 19:51:04 +0100 Subject: [PATCH] First implementation of using Node/Presenter/UI on RoomList (no DI) --- .../java/io/element/android/x/MainActivity.kt | 2 +- .../android/x/node/LoggedInFlowNode.kt | 25 ++- .../android/x/node/NotLoggedInFlowNode.kt | 9 + .../io/element/android/x/node/RootFlowNode.kt | 25 ++- .../features/roomlist/LastMessageFormatter.kt | 11 +- .../roomlist/NodePresenterConnector.kt | 29 ++++ .../x/features/roomlist/RoomListNode.kt | 43 +++++ .../x/features/roomlist/RoomListPresenter.kt | 148 ++++++++++++++++ .../{RoomListScreen.kt => RoomListView.kt} | 59 +++---- .../x/features/roomlist/RoomListViewModel.kt | 159 ------------------ .../features/roomlist/model/RoomListScreen.kt | 21 +++ .../roomlist/model/RoomListViewState.kt | 16 -- .../android/x/core/architecture/Presenter.kt | 9 + 13 files changed, 326 insertions(+), 230 deletions(-) create mode 100644 features/roomlist/src/main/java/io/element/android/x/features/roomlist/NodePresenterConnector.kt create mode 100644 features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt create mode 100644 features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListPresenter.kt rename features/roomlist/src/main/java/io/element/android/x/features/roomlist/{RoomListScreen.kt => RoomListView.kt} (76%) delete mode 100644 features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewModel.kt create mode 100644 features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListScreen.kt delete mode 100644 features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListViewState.kt create mode 100644 libraries/core/src/main/java/io/element/android/x/core/architecture/Presenter.kt diff --git a/app/src/main/java/io/element/android/x/MainActivity.kt b/app/src/main/java/io/element/android/x/MainActivity.kt index 0ca348698d..4fdb426392 100644 --- a/app/src/main/java/io/element/android/x/MainActivity.kt +++ b/app/src/main/java/io/element/android/x/MainActivity.kt @@ -41,7 +41,7 @@ class MainActivity : NodeComponentActivity(), DaggerComponentOwner { NodeHost(integrationPoint = appyxIntegrationPoint) { RootFlowNode( buildContext = it, - daggerComponentOwner = this, + appComponentOwner = this, matrix = appBindings.matrix(), sessionComponentsOwner = appBindings.sessionComponentsOwner() ) diff --git a/app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt b/app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt index 2e1b7d8c9c..28914b78c0 100644 --- a/app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt +++ b/app/src/main/java/io/element/android/x/node/LoggedInFlowNode.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.ParentNode @@ -12,14 +13,18 @@ import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push import io.element.android.x.core.di.viewModelSupportNode import io.element.android.x.features.messages.MessagesScreen -import io.element.android.x.features.roomlist.RoomListScreen +import io.element.android.x.features.roomlist.RoomListNode +import io.element.android.x.features.roomlist.RoomListPresenter +import io.element.android.x.matrix.MatrixClient import io.element.android.x.matrix.core.RoomId import io.element.android.x.matrix.core.SessionId import kotlinx.parcelize.Parcelize +import timber.log.Timber class LoggedInFlowNode( buildContext: BuildContext, val sessionId: SessionId, + private val matrixClient: MatrixClient, private val backstack: BackStack = BackStack( initialElement = NavTarget.RoomList, savedStateMap = buildContext.savedStateMap, @@ -29,6 +34,13 @@ class LoggedInFlowNode( buildContext = buildContext ) { + init { + lifecycle.subscribe( + onCreate = { Timber.v("OnCreate") }, + onDestroy = { Timber.v("OnDestroy") } + ) + } + sealed interface NavTarget : Parcelable { @Parcelize object RoomList : NavTarget @@ -39,11 +51,12 @@ class LoggedInFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - NavTarget.RoomList -> viewModelSupportNode(buildContext) { - RoomListScreen( - onRoomClicked = { backstack.push(NavTarget.Messages(it)) } - ) - } + NavTarget.RoomList -> RoomListNode( + buildContext = buildContext, + presenter = RoomListPresenter(matrixClient), + onRoomClicked = { + backstack.push(NavTarget.Messages(it)) + }) is NavTarget.Messages -> viewModelSupportNode(buildContext) { MessagesScreen( roomId = navTarget.roomId.value, diff --git a/app/src/main/java/io/element/android/x/node/NotLoggedInFlowNode.kt b/app/src/main/java/io/element/android/x/node/NotLoggedInFlowNode.kt index 4be72a0696..e9c848539e 100644 --- a/app/src/main/java/io/element/android/x/node/NotLoggedInFlowNode.kt +++ b/app/src/main/java/io/element/android/x/node/NotLoggedInFlowNode.kt @@ -4,6 +4,7 @@ import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.ParentNode @@ -13,6 +14,7 @@ import io.element.android.x.core.di.viewModelSupportNode import io.element.android.x.features.login.node.LoginFlowNode import io.element.android.x.features.onboarding.OnBoardingScreen import kotlinx.parcelize.Parcelize +import timber.log.Timber class NotLoggedInFlowNode( buildContext: BuildContext, @@ -25,6 +27,13 @@ class NotLoggedInFlowNode( buildContext = buildContext ) { + init { + lifecycle.subscribe( + onCreate = { Timber.v("OnCreate") }, + onDestroy = { Timber.v("OnDestroy") } + ) + } + sealed interface NavTarget : Parcelable { @Parcelize object OnBoarding : NavTarget diff --git a/app/src/main/java/io/element/android/x/node/RootFlowNode.kt b/app/src/main/java/io/element/android/x/node/RootFlowNode.kt index fa80bc5209..643763e5ad 100644 --- a/app/src/main/java/io/element/android/x/node/RootFlowNode.kt +++ b/app/src/main/java/io/element/android/x/node/RootFlowNode.kt @@ -25,6 +25,7 @@ import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.ParentNode import com.bumble.appyx.core.node.node import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.replace import io.element.android.x.BuildConfig import io.element.android.x.component.ShowkaseButton @@ -61,7 +62,7 @@ class RootFlowNode( initialElement = NavTarget.SplashScreen, savedStateMap = buildContext.savedStateMap, ), - private val daggerComponentOwner: DaggerComponentOwner, + private val appComponentOwner: DaggerComponentOwner, private val matrix: Matrix, private val sessionComponentsOwner: SessionComponentsOwner, ) : @@ -71,23 +72,32 @@ class RootFlowNode( plugins = listOf(SessionComponentsOwnerInteractor(sessionComponentsOwner)), ), - DaggerComponentOwner by daggerComponentOwner { + DaggerComponentOwner by appComponentOwner { + + init { + Timber.v("Init") + lifecycle.subscribe( + onCreate = { Timber.v("OnCreate") }, + onDestroy = { Timber.v("OnDestroy") } + ) + } init { matrix.isLoggedIn() .distinctUntilChanged() .onEach { isLoggedIn -> + Timber.v("IsLoggedIn") if (isLoggedIn) { val matrixClient = matrix.restoreSession() if (matrixClient == null) { - backstack.replace(NavTarget.NotLoggedInFlow) + backstack.newRoot(NavTarget.NotLoggedInFlow) } else { matrixClient.startSync() sessionComponentsOwner.create(matrixClient) - backstack.replace(NavTarget.LoggedInFlow(matrixClient.sessionId)) + backstack.newRoot(NavTarget.LoggedInFlow(matrixClient.sessionId)) } } else { - backstack.replace(NavTarget.NotLoggedInFlow) + backstack.newRoot(NavTarget.NotLoggedInFlow) } } .launchIn(lifecycleScope) @@ -124,7 +134,10 @@ class RootFlowNode( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - is NavTarget.LoggedInFlow -> LoggedInFlowNode(buildContext, navTarget.sessionId) + is NavTarget.LoggedInFlow -> { + val matrixClient = sessionComponentsOwner.activeSessionComponent!!.matrixClient() + LoggedInFlowNode(buildContext, navTarget.sessionId, matrixClient) + } NavTarget.NotLoggedInFlow -> NotLoggedInFlowNode(buildContext) NavTarget.SplashScreen -> node(buildContext) { Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) { diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/LastMessageFormatter.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/LastMessageFormatter.kt index d06eb20fbc..bc59f0544c 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/LastMessageFormatter.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/LastMessageFormatter.kt @@ -2,10 +2,6 @@ package io.element.android.x.features.roomlist import android.text.format.DateFormat import android.text.format.DateUtils -import java.time.Period -import java.time.format.DateTimeFormatter -import java.util.Locale -import kotlin.math.absoluteValue import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime @@ -14,8 +10,13 @@ import kotlinx.datetime.toInstant import kotlinx.datetime.toJavaLocalDate import kotlinx.datetime.toJavaLocalDateTime import kotlinx.datetime.toLocalDateTime +import java.time.Period +import java.time.format.DateTimeFormatter +import java.util.Locale +import javax.inject.Inject +import kotlin.math.absoluteValue -class LastMessageFormatter( +class LastMessageFormatter @Inject constructor( private val clock: Clock = Clock.System, private val locale: Locale = Locale.getDefault() ) { diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/NodePresenterConnector.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/NodePresenterConnector.kt new file mode 100644 index 0000000000..7012714324 --- /dev/null +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/NodePresenterConnector.kt @@ -0,0 +1,29 @@ +package io.element.android.x.features.roomlist + +import androidx.compose.ui.platform.AndroidUiDispatcher +import androidx.lifecycle.lifecycleScope +import app.cash.molecule.RecompositionClock +import app.cash.molecule.launchMolecule +import com.bumble.appyx.core.node.Node +import io.element.android.x.core.architecture.Presenter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.StateFlow + +inline fun Node.presenterConnector(presenter: Presenter): NodePresenterConnector { + return NodePresenterConnector(node = this, presenter = presenter) +} + +class NodePresenterConnector(private val node: Node, presenter: Presenter) { + + private val moleculeScope = CoroutineScope(node.lifecycleScope.coroutineContext + AndroidUiDispatcher.Main) + private val eventFlow: MutableSharedFlow = MutableSharedFlow(extraBufferCapacity = 64) + + val stateFlow: StateFlow = moleculeScope.launchMolecule(RecompositionClock.ContextClock) { + presenter.present(events = eventFlow) + } + + fun emitEvent(event: Event) { + eventFlow.tryEmit(event) + } +} diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt new file mode 100644 index 0000000000..48b5cfd6eb --- /dev/null +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListNode.kt @@ -0,0 +1,43 @@ +package io.element.android.x.features.roomlist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.x.features.roomlist.model.RoomListScreen +import io.element.android.x.matrix.core.RoomId + +class RoomListNode( + buildContext: BuildContext, + presenter: RoomListPresenter, + private val onRoomClicked: (RoomId) -> Unit +) : Node(buildContext) { + + private val connector = presenterConnector(presenter) + + private fun updateFilter(filter: String) { + connector.emitEvent(RoomListScreen.Event.UpdateFilter(filter)) + } + + private fun updateVisibleRange(range: IntRange) { + connector.emitEvent((RoomListScreen.Event.UpdateVisibleRange(range))) + } + + private fun logout() { + connector.emitEvent(RoomListScreen.Event.Logout) + } + + @Composable + override fun View(modifier: Modifier) { + val state by connector.stateFlow.collectAsState() + RoomListView( + state = state, + onRoomClicked = onRoomClicked, + onFilterChanged = this::updateFilter, + onScrollOver = this::updateVisibleRange, + onLogoutClicked = this::logout + ) + } +} diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListPresenter.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListPresenter.kt new file mode 100644 index 0000000000..3fad20ab7f --- /dev/null +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListPresenter.kt @@ -0,0 +1,148 @@ +package io.element.android.x.features.roomlist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import io.element.android.x.core.architecture.Presenter +import io.element.android.x.core.coroutine.parallelMap +import io.element.android.x.designsystem.components.avatar.AvatarData +import io.element.android.x.designsystem.components.avatar.AvatarSize +import io.element.android.x.features.roomlist.model.MatrixUser +import io.element.android.x.features.roomlist.model.RoomListRoomSummary +import io.element.android.x.features.roomlist.model.RoomListRoomSummaryPlaceholders +import io.element.android.x.features.roomlist.model.RoomListScreen +import io.element.android.x.matrix.MatrixClient +import io.element.android.x.matrix.media.MediaResolver +import io.element.android.x.matrix.room.RoomSummary +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +private const val extendedRangeSize = 40 + +class RoomListPresenter @Inject constructor( + private val client: MatrixClient, + private val lastMessageFormatter: LastMessageFormatter = LastMessageFormatter(), +) : Presenter { + + @Composable + override fun present(events: Flow): RoomListScreen.State { + val matrixUser: MutableState = remember { + mutableStateOf(null) + } + var filter by rememberSaveable { mutableStateOf("") } + val isLoginOut = rememberSaveable { mutableStateOf(false) } + val roomSummaries by client + .roomSummaryDataSource() + .roomSummaries() + .collectAsState(initial = null) + + val filteredRoomSummaries: MutableState> = remember { + mutableStateOf(persistentListOf()) + } + LaunchedEffect(Unit) { + initialLoad(matrixUser) + events.collect { event -> + when (event) { + RoomListScreen.Event.Logout -> logout(isLoginOut) + is RoomListScreen.Event.UpdateFilter -> filter = event.newFilter + is RoomListScreen.Event.UpdateVisibleRange -> updateVisibleRange(event.range) + } + } + } + LaunchedEffect(roomSummaries, filter) { + filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter) + } + return RoomListScreen.State( + matrixUser = matrixUser.value, + roomList = filteredRoomSummaries.value, + filter = filter, + isLoginOut = isLoginOut.value + ) + } + + private suspend fun updateFilteredRoomSummaries(roomSummaries: List?, filter: String): ImmutableList { + val mappedRoomSummaries = mapRoomSummaries(roomSummaries.orEmpty()) + return if (filter.isEmpty()) { + mappedRoomSummaries + } else { + mappedRoomSummaries.filter { it.name.contains(filter, ignoreCase = true) } + }.toImmutableList() + } + + private suspend fun initialLoad(matrixUser: MutableState) { + val userAvatarUrl = client.loadUserAvatarURLString().getOrNull() + val userDisplayName = client.loadUserDisplayName().getOrNull() + val avatarData = + loadAvatarData( + userDisplayName ?: client.userId().value, + userAvatarUrl, + AvatarSize.SMALL + ) + matrixUser.value = MatrixUser( + username = userDisplayName ?: client.userId().value, + avatarUrl = userAvatarUrl, + avatarData = avatarData, + ) + } + + private suspend fun logout(isLoginOut: MutableState) { + isLoginOut.value = true + delay(2000) + client.logout() + isLoginOut.value = false + } + + private fun updateVisibleRange(range: IntRange) { + if (range.isEmpty()) return + val midExtendedRangeSize = extendedRangeSize / 2 + val extendedRangeStart = (range.first - midExtendedRangeSize).coerceAtLeast(0) + // Safe to give bigger size than room list + val extendedRangeEnd = range.last + midExtendedRangeSize + val extendedRange = IntRange(extendedRangeStart, extendedRangeEnd) + client.roomSummaryDataSource().setSlidingSyncRange(extendedRange) + } + + private suspend fun mapRoomSummaries( + roomSummaries: List + ): List { + return roomSummaries.parallelMap { roomSummary -> + when (roomSummary) { + is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier) + is RoomSummary.Filled -> { + val avatarData = loadAvatarData( + roomSummary.details.name, + roomSummary.details.avatarURLString + ) + RoomListRoomSummary( + id = roomSummary.identifier(), + name = roomSummary.details.name, + hasUnread = roomSummary.details.unreadNotificationCount > 0, + timestamp = lastMessageFormatter.format(roomSummary.details.lastMessageTimestamp), + lastMessage = roomSummary.details.lastMessage, + avatarData = avatarData, + ) + } + } + } + } + + private suspend fun loadAvatarData( + name: String, + url: String?, + size: AvatarSize = AvatarSize.MEDIUM + ): AvatarData { + val model = client.mediaResolver() + .resolve(url, kind = MediaResolver.Kind.Thumbnail(size.value)) + return AvatarData(name, model, size) + } +} diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListView.kt similarity index 76% rename from features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt rename to features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListView.kt index dff4871efe..e6a8e0e5fa 100644 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListScreen.kt +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListView.kt @@ -5,7 +5,6 @@ package io.element.android.x.features.roomlist import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api @@ -21,10 +20,6 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Velocity -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.Success -import com.airbnb.mvrx.compose.collectAsState -import com.airbnb.mvrx.compose.mavericksViewModel import io.element.android.x.core.compose.LogCompositions import io.element.android.x.designsystem.ElementXTheme import io.element.android.x.designsystem.components.ProgressDialog @@ -33,41 +28,35 @@ import io.element.android.x.features.roomlist.components.RoomListTopBar import io.element.android.x.features.roomlist.components.RoomSummaryRow import io.element.android.x.features.roomlist.model.MatrixUser import io.element.android.x.features.roomlist.model.RoomListRoomSummary -import io.element.android.x.features.roomlist.model.RoomListViewState +import io.element.android.x.features.roomlist.model.RoomListScreen import io.element.android.x.features.roomlist.model.stubbedRoomSummaries import io.element.android.x.matrix.core.RoomId import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList @Composable -fun RoomListScreen( - viewModel: RoomListViewModel = mavericksViewModel(), - onSuccessLogout: () -> Unit = { }, - onRoomClicked: (RoomId) -> Unit = { } +fun RoomListView( + state: RoomListScreen.State, + modifier: Modifier = Modifier, + onRoomClicked: (RoomId) -> Unit = {}, + onFilterChanged: (String) -> Unit = {}, + onLogoutClicked: () -> Unit = {}, + onScrollOver: (IntRange) -> Unit = {}, ) { - val logoutAction by viewModel.collectAsState(RoomListViewState::logoutAction) - val filter by viewModel.collectAsState(RoomListViewState::filter) - if (logoutAction is Success) { - onSuccessLogout() - return - } - LogCompositions(tag = "RoomListScreen", msg = "Root") - val roomSummaries by viewModel.collectAsState(RoomListViewState::rooms) - val matrixUser by viewModel.collectAsState(RoomListViewState::user) - RoomListContent( - roomSummaries = roomSummaries().orEmpty().toImmutableList(), - matrixUser = matrixUser(), + RoomListView( + roomSummaries = state.roomList, + matrixUser = state.matrixUser, + filter = state.filter, + isLoginOut = state.isLoginOut, + modifier = modifier, onRoomClicked = onRoomClicked, - onLogoutClicked = viewModel::logout, - isLoginOut = logoutAction is Loading, - filter = filter, - onFilterChanged = viewModel::filterRoom, - onScrollOver = viewModel::updateVisibleRange + onFilterChanged = onFilterChanged, + onLogoutClicked = onLogoutClicked, + onScrollOver = onScrollOver ) } @Composable -fun RoomListContent( +fun RoomListView( roomSummaries: ImmutableList, matrixUser: MatrixUser?, filter: String, @@ -141,15 +130,11 @@ fun RoomListContent( private fun RoomListRoomSummary.contentType() = isPlaceholder -private fun LazyListState.isScrolled(): Boolean { - return firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0 -} - @Preview @Composable -fun PreviewableRoomListContent() { +fun PreviewableRoomListView() { ElementXTheme(darkTheme = false) { - RoomListContent( + RoomListView( roomSummaries = stubbedRoomSummaries(), matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")), onRoomClicked = {}, @@ -164,9 +149,9 @@ fun PreviewableRoomListContent() { @Preview @Composable -fun PreviewableDarkRoomListContent() { +fun PreviewableDarkRoomListView() { ElementXTheme(darkTheme = true) { - RoomListContent( + RoomListView( roomSummaries = stubbedRoomSummaries(), matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")), onRoomClicked = {}, diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewModel.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewModel.kt deleted file mode 100644 index 5137f83761..0000000000 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/RoomListViewModel.kt +++ /dev/null @@ -1,159 +0,0 @@ -package io.element.android.x.features.roomlist - -import com.airbnb.mvrx.* -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import io.element.android.x.anvilannotations.ContributesViewModel -import io.element.android.x.core.coroutine.parallelMap -import io.element.android.x.core.di.daggerMavericksViewModelFactory -import io.element.android.x.designsystem.components.avatar.AvatarData -import io.element.android.x.designsystem.components.avatar.AvatarSize -import io.element.android.x.di.SessionScope -import io.element.android.x.features.roomlist.model.MatrixUser -import io.element.android.x.features.roomlist.model.RoomListRoomSummary -import io.element.android.x.features.roomlist.model.RoomListRoomSummaryPlaceholders -import io.element.android.x.features.roomlist.model.RoomListViewState -import io.element.android.x.matrix.MatrixClient -import io.element.android.x.matrix.media.MediaResolver -import io.element.android.x.matrix.room.RoomSummary -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch - -private const val extendedRangeSize = 40 - -@ContributesViewModel(SessionScope::class) -class RoomListViewModel @AssistedInject constructor( - private val client: MatrixClient, - @Assisted initialState: RoomListViewState -) : - MavericksViewModel(initialState) { - - companion object : MavericksViewModelFactory by daggerMavericksViewModelFactory() - - private val lastMessageFormatter = LastMessageFormatter() - - init { - handleInit() - } - - fun logout() { - viewModelScope.launch { - suspend { - delay(2000) - client.logout() - }.execute { - copy(logoutAction = it) - } - } - } - - fun filterRoom(filter: String) { - setState { - copy( - filter = filter - ) - } - } - - fun updateVisibleRange(range: IntRange) { - viewModelScope.launch { - if (range.isEmpty()) return@launch - val midExtendedRangeSize = extendedRangeSize / 2 - val extendedRangeStart = (range.first - midExtendedRangeSize).coerceAtLeast(0) - // Safe to give bigger size than room list - val extendedRangeEnd = range.last + midExtendedRangeSize - val extendedRange = IntRange(extendedRangeStart, extendedRangeEnd) - client.roomSummaryDataSource().setSlidingSyncRange(extendedRange) - } - } - - private fun handleInit() { - suspend { - val userAvatarUrl = client.loadUserAvatarURLString().getOrNull() - val userDisplayName = client.loadUserDisplayName().getOrNull() - val avatarData = - loadAvatarData( - userDisplayName ?: client.userId().value, - userAvatarUrl, - AvatarSize.SMALL - ) - MatrixUser( - username = userDisplayName ?: client.userId().value, - avatarUrl = userAvatarUrl, - avatarData = avatarData, - ) - }.execute { - copy(user = it) - } - - // Observe the room list and the filter - combine( - client.roomSummaryDataSource().roomSummaries() - .map(::mapRoomSummaries) - .flowOn(Dispatchers.Default), - stateFlow - .map { it.filter } - .distinctUntilChanged(), - ) { list, filter -> - if (filter.isEmpty()) { - list - } else { - list.filter { it.name.contains(filter, ignoreCase = true) } - } - } - .execute { - copy( - rooms = when { - it is Loading || - // Note: this second case will prevent to handle correctly the empty case - (it is Success && it().isEmpty() && filter.isEmpty()) -> { - // Show fake placeholders to avoid having empty screen - Loading(RoomListRoomSummaryPlaceholders.createFakeList(size = 16)) - } - else -> { - it - } - } - ) - } - } - - private suspend fun mapRoomSummaries( - roomSummaries: List - ): List { - return roomSummaries.parallelMap { roomSummary -> - when (roomSummary) { - is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier) - is RoomSummary.Filled -> { - val avatarData = loadAvatarData( - roomSummary.details.name, - roomSummary.details.avatarURLString - ) - RoomListRoomSummary( - id = roomSummary.identifier(), - name = roomSummary.details.name, - hasUnread = roomSummary.details.unreadNotificationCount > 0, - timestamp = lastMessageFormatter.format(roomSummary.details.lastMessageTimestamp), - lastMessage = roomSummary.details.lastMessage, - avatarData = avatarData, - ) - } - } - } - } - - private suspend fun loadAvatarData( - name: String, - url: String?, - size: AvatarSize = AvatarSize.MEDIUM - ): AvatarData { - val model = client.mediaResolver() - .resolve(url, kind = MediaResolver.Kind.Thumbnail(size.value)) - return AvatarData(name, model, size) - } -} diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListScreen.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListScreen.kt new file mode 100644 index 0000000000..1db974bd7b --- /dev/null +++ b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListScreen.kt @@ -0,0 +1,21 @@ +package io.element.android.x.features.roomlist.model + +import androidx.compose.runtime.Stable +import kotlinx.collections.immutable.ImmutableList + +object RoomListScreen { + + @Stable + data class State( + val matrixUser: MatrixUser?, + val roomList: ImmutableList, + val filter: String, + val isLoginOut: Boolean, + ) + + sealed interface Event { + object Logout : Event + data class UpdateFilter(val newFilter: String) : Event + data class UpdateVisibleRange(val range: IntRange): Event + } +} diff --git a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListViewState.kt b/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListViewState.kt deleted file mode 100644 index 86414c4a0e..0000000000 --- a/features/roomlist/src/main/java/io/element/android/x/features/roomlist/model/RoomListViewState.kt +++ /dev/null @@ -1,16 +0,0 @@ -package io.element.android.x.features.roomlist.model - -import com.airbnb.mvrx.Async -import com.airbnb.mvrx.MavericksState -import com.airbnb.mvrx.Uninitialized -import io.element.android.x.matrix.core.RoomId - -data class RoomListViewState( - val user: Async = Uninitialized, - // Will contain the filtered rooms, using ::filter (if filter is not empty) - val rooms: Async> = Uninitialized, - val filter: String = "", - val canLoadMore: Boolean = false, - val logoutAction: Async = Uninitialized, - val roomsById: Map = emptyMap() -) : MavericksState diff --git a/libraries/core/src/main/java/io/element/android/x/core/architecture/Presenter.kt b/libraries/core/src/main/java/io/element/android/x/core/architecture/Presenter.kt new file mode 100644 index 0000000000..a48fa24b7c --- /dev/null +++ b/libraries/core/src/main/java/io/element/android/x/core/architecture/Presenter.kt @@ -0,0 +1,9 @@ +package io.element.android.x.core.architecture + +import androidx.compose.runtime.Composable +import kotlinx.coroutines.flow.Flow + +interface Presenter { + @Composable + fun present(events: Flow): State +}