Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: feature flag for paginated conversation list [WPB-12070] 🍒 #3612

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ class ConversationModule {
fun provideObserveConversationListDetails(conversationScope: ConversationScope): ObserveConversationListDetailsUseCase =
conversationScope.observeConversationListDetails

@ViewModelScoped
@Provides
fun provideObserveConversationListDetailsWithEvents(conversationScope: ConversationScope) =
conversationScope.observeConversationListDetailsWithEvents

@ViewModelScoped
@Provides
fun provideObserveConversationUseCase(conversationScope: ConversationScope): GetOneToOneConversationUseCase =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,22 @@ package com.wire.android.ui.home.conversationslist

import androidx.compose.runtime.Stable
import androidx.paging.PagingData
import com.wire.android.ui.home.conversationslist.model.ConversationFolder
import com.wire.android.ui.home.conversationslist.model.ConversationFolderItem
import com.wire.kalium.logic.data.conversation.ConversationFilter
import com.wire.android.ui.home.conversationslist.model.ConversationItem
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow

@Stable
data class ConversationListState(
val foldersWithConversations: Flow<PagingData<ConversationFolderItem>> = emptyFlow(),
val filter: ConversationFilter = ConversationFilter.ALL,
val domain: String = ""
)
sealed interface ConversationListState {
data class Paginated(
val conversations: Flow<PagingData<ConversationFolderItem>>,
val domain: String = "",
) : ConversationListState
data class NotPaginated(
val isLoading: Boolean = true,
val conversations: ImmutableMap<ConversationFolder, List<ConversationItem>> = persistentMapOf(),
val domain: String = "",
) : ConversationListState
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,27 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.insertSeparators
import com.wire.android.BuildConfig
import com.wire.android.appLogger
import com.wire.android.di.CurrentAccount
import com.wire.android.mapper.UserTypeMapper
import com.wire.android.mapper.toConversationItem
import com.wire.android.model.SnackBarMessage
import com.wire.android.ui.common.bottomsheet.conversation.ConversationTypeDetail
import com.wire.android.ui.common.dialogs.BlockUserDialogState
import com.wire.android.ui.home.HomeSnackBarMessage
import com.wire.android.ui.home.conversations.search.DEFAULT_SEARCH_QUERY_DEBOUNCE
import com.wire.android.ui.home.conversations.usecase.GetConversationsFromSearchUseCase
import com.wire.android.ui.home.conversationslist.common.previewConversationFoldersFlow
import com.wire.android.ui.home.conversationslist.model.BadgeEventType
import com.wire.android.ui.home.conversationslist.model.ConversationFolder
import com.wire.android.ui.home.conversationslist.model.ConversationFolderItem
import com.wire.android.ui.home.conversationslist.model.ConversationItem
import com.wire.android.ui.home.conversationslist.model.ConversationsSource
import com.wire.android.ui.home.conversationslist.model.DialogState
import com.wire.android.ui.home.conversationslist.model.GroupDialogState
import com.wire.android.util.dispatchers.DispatcherProvider
import com.wire.android.util.ui.WireSessionImageLoader
import com.wire.kalium.logic.data.conversation.Conversation
import com.wire.kalium.logic.data.conversation.ConversationFilter
import com.wire.kalium.logic.data.conversation.MutedConversationStatus
Expand All @@ -53,6 +59,7 @@ import com.wire.kalium.logic.feature.conversation.ArchiveStatusUpdateResult
import com.wire.kalium.logic.feature.conversation.ClearConversationContentUseCase
import com.wire.kalium.logic.feature.conversation.ConversationUpdateStatusResult
import com.wire.kalium.logic.feature.conversation.LeaveConversationUseCase
import com.wire.kalium.logic.feature.conversation.ObserveConversationListDetailsWithEventsUseCase
import com.wire.kalium.logic.feature.conversation.RefreshConversationsWithoutMetadataUseCase
import com.wire.kalium.logic.feature.conversation.RemoveMemberFromConversationUseCase
import com.wire.kalium.logic.feature.conversation.UpdateConversationArchivedStatusUseCase
Expand All @@ -65,13 +72,15 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
Expand All @@ -83,7 +92,7 @@ interface ConversationListViewModel {
val infoMessage: SharedFlow<SnackBarMessage> get() = MutableSharedFlow()
val closeBottomSheet: SharedFlow<Unit> get() = MutableSharedFlow()
val requestInProgress: Boolean get() = false
val conversationListState: ConversationListState get() = ConversationListState()
val conversationListState: ConversationListState get() = ConversationListState.Paginated(emptyFlow())
suspend fun refreshMissingMetadata() {}
fun moveConversationToArchive(
dialogState: DialogState,
Expand All @@ -105,7 +114,7 @@ interface ConversationListViewModel {
class ConversationListViewModelPreview(
foldersWithConversations: Flow<PagingData<ConversationFolderItem>> = previewConversationFoldersFlow(),
) : ConversationListViewModel {
override val conversationListState = ConversationListState(foldersWithConversations)
override val conversationListState = ConversationListState.Paginated(foldersWithConversations)
}

@Suppress("MagicNumber", "TooManyFunctions", "LongParameterList")
Expand All @@ -115,6 +124,7 @@ class ConversationListViewModelImpl @AssistedInject constructor(
dispatcher: DispatcherProvider,
private val updateConversationMutedStatus: UpdateConversationMutedStatusUseCase,
private val getConversationsPaginated: GetConversationsFromSearchUseCase,
private val observeConversationListDetailsWithEvents: ObserveConversationListDetailsWithEventsUseCase,
private val leaveConversation: LeaveConversationUseCase,
private val deleteTeamConversation: DeleteTeamConversationUseCase,
private val blockUserUseCase: BlockUserUseCase,
Expand All @@ -124,6 +134,8 @@ class ConversationListViewModelImpl @AssistedInject constructor(
private val refreshConversationsWithoutMetadata: RefreshConversationsWithoutMetadataUseCase,
private val updateConversationArchivedStatus: UpdateConversationArchivedStatusUseCase,
@CurrentAccount val currentAccount: UserId,
private val wireSessionImageLoader: WireSessionImageLoader,
private val userTypeMapper: UserTypeMapper,
) : ConversationListViewModel, ViewModel() {

@AssistedFactory
Expand All @@ -150,7 +162,7 @@ class ConversationListViewModelImpl @AssistedInject constructor(
ConversationsSource.ARCHIVE -> false
}

private val conversationsFlow: Flow<PagingData<ConversationFolderItem>> = searchQueryFlow
private val conversationsPaginatedFlow: Flow<PagingData<ConversationFolderItem>> = searchQueryFlow
.debounce { if (it.isEmpty()) 0L else DEFAULT_SEARCH_QUERY_DEBOUNCE }
.onStart { emit("") }
.distinctUntilChanged()
Expand Down Expand Up @@ -186,10 +198,62 @@ class ConversationListViewModelImpl @AssistedInject constructor(
}
.flowOn(dispatcher.io())

override val conversationListState: ConversationListState = ConversationListState(
foldersWithConversations = conversationsFlow,
domain = currentAccount.domain
)
private var notPaginatedConversationListState by mutableStateOf(ConversationListState.NotPaginated())
override val conversationListState: ConversationListState
get() = if (BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED) {
ConversationListState.Paginated(
conversations = conversationsPaginatedFlow,
domain = currentAccount.domain
)
} else {
notPaginatedConversationListState
}

init {
if (!BuildConfig.PAGINATED_CONVERSATION_LIST_ENABLED) {
viewModelScope.launch {
searchQueryFlow
.debounce { if (it.isEmpty()) 0L else DEFAULT_SEARCH_QUERY_DEBOUNCE }
.onStart { emit("") }
.distinctUntilChanged()
.flatMapLatest { searchQuery: String ->
observeConversationListDetailsWithEvents(
fromArchive = conversationsSource == ConversationsSource.ARCHIVE
).map {
it.map { conversationDetails ->
conversationDetails.toConversationItem(
wireSessionImageLoader = wireSessionImageLoader,
userTypeMapper = userTypeMapper,
searchQuery = searchQuery,
)
} to searchQuery
}
}
.map { (conversationItems, searchQuery) ->
val filteredConversationItems = filterConversation(
conversationDetails = conversationItems,
filter = conversationsSource.toFilter()
)
if (searchQuery.isEmpty()) {
filteredConversationItems.withFolders(source = conversationsSource).toImmutableMap()
} else {
searchConversation(
conversationDetails = filteredConversationItems,
searchQuery = searchQuery
).withFolders(source = conversationsSource).toImmutableMap()
}
}
.flowOn(dispatcher.io())
.collect {
notPaginatedConversationListState = notPaginatedConversationListState.copy(
isLoading = false,
conversations = it,
domain = currentAccount.domain
)
}
}
}
}

override fun searchQueryChanged(searchQuery: String) {
viewModelScope.launch {
Expand Down Expand Up @@ -376,3 +440,78 @@ private fun ConversationsSource.toFilter(): ConversationFilter = when (this) {
ConversationsSource.FAVORITES -> ConversationFilter.FAVORITES
ConversationsSource.ONE_ON_ONE -> ConversationFilter.ONE_ON_ONE
}

@Suppress("ComplexMethod")
private fun List<ConversationItem>.withFolders(source: ConversationsSource): Map<ConversationFolder, List<ConversationItem>> {
return when (source) {
ConversationsSource.ARCHIVE -> {
buildMap {
if ([email protected]()) {
put(ConversationFolder.WithoutHeader, this@withFolders)
}
}
}

ConversationsSource.FAVORITES,
ConversationsSource.GROUPS,
ConversationsSource.ONE_ON_ONE,
ConversationsSource.MAIN -> {
val unreadConversations = filter {
when (it.mutedStatus) {
MutedConversationStatus.AllAllowed -> when (it.badgeEventType) {
BadgeEventType.Blocked -> false
BadgeEventType.Deleted -> false
BadgeEventType.Knock -> true
BadgeEventType.MissedCall -> true
BadgeEventType.None -> false
BadgeEventType.ReceivedConnectionRequest -> true
BadgeEventType.SentConnectRequest -> false
BadgeEventType.UnreadMention -> true
is BadgeEventType.UnreadMessage -> true
BadgeEventType.UnreadReply -> true
}

MutedConversationStatus.OnlyMentionsAndRepliesAllowed ->
when (it.badgeEventType) {
BadgeEventType.UnreadReply -> true
BadgeEventType.UnreadMention -> true
BadgeEventType.ReceivedConnectionRequest -> true
else -> false
}

MutedConversationStatus.AllMuted -> false
} || (it is ConversationItem.GroupConversation && it.hasOnGoingCall)
}

val remainingConversations = this - unreadConversations.toSet()

buildMap {
if (unreadConversations.isNotEmpty()) {
put(ConversationFolder.Predefined.NewActivities, unreadConversations)
}
if (remainingConversations.isNotEmpty()) {
put(ConversationFolder.Predefined.Conversations, remainingConversations)
}
}
}
}
}

private fun searchConversation(conversationDetails: List<ConversationItem>, searchQuery: String): List<ConversationItem> =
conversationDetails.filter { details ->
when (details) {
is ConversationItem.ConnectionConversation -> details.conversationInfo.name.contains(searchQuery, true)
is ConversationItem.GroupConversation -> details.groupName.contains(searchQuery, true)
is ConversationItem.PrivateConversation -> details.conversationInfo.name.contains(searchQuery, true)
}
}

private fun filterConversation(conversationDetails: List<ConversationItem>, filter: ConversationFilter): List<ConversationItem> =
conversationDetails.filter { details ->
when (filter) {
ConversationFilter.ALL -> true
ConversationFilter.FAVORITES -> false
ConversationFilter.GROUPS -> details is ConversationItem.GroupConversation
ConversationFilter.ONE_ON_ONE -> details is ConversationItem.PrivateConversation
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -178,36 +178,65 @@ fun ConversationsScreenContent(
}
}

with(conversationListViewModel.conversationListState) {
val lazyPagingItems = foldersWithConversations.collectAsLazyPagingItems()
var showLoading by remember { mutableStateOf(!initiallyLoaded) }
if (lazyPagingItems.loadState.refresh != LoadState.Loading && showLoading) {
showLoading = false
when (val state = conversationListViewModel.conversationListState) {
is ConversationListState.Paginated -> {
val lazyPagingItems = state.conversations.collectAsLazyPagingItems()
var showLoading by remember { mutableStateOf(!initiallyLoaded) }
if (lazyPagingItems.loadState.refresh != LoadState.Loading && showLoading) {
showLoading = false
}

when {
// when conversation list is not yet fetched, show loading indicator
showLoading -> loadingListContent(lazyListState)
// when there is at least one conversation
lazyPagingItems.itemCount > 0 -> ConversationList(
lazyPagingConversations = lazyPagingItems,
lazyListState = lazyListState,
onOpenConversation = onOpenConversation,
onEditConversation = onEditConversationItem,
onOpenUserProfile = onOpenUserProfile,
onJoinCall = onJoinCall,
onAudioPermissionPermanentlyDenied = {
permissionPermanentlyDeniedDialogState.show(
PermissionPermanentlyDeniedDialogState.Visible(
R.string.app_permission_dialog_title,
R.string.call_permission_dialog_description
)
)
}
)
// when there is no conversation in any folder
searchBarState.isSearchActive -> SearchConversationsEmptyContent(onNewConversationClicked = onNewConversationClicked)
else -> emptyListContent(state.domain)
}
}

when {
// when conversation list is not yet fetched, show loading indicator
showLoading -> loadingListContent(lazyListState)
// when there is at least one conversation
lazyPagingItems.itemCount > 0 -> ConversationList(
lazyPagingConversations = lazyPagingItems,
lazyListState = lazyListState,
onOpenConversation = onOpenConversation,
onEditConversation = onEditConversationItem,
onOpenUserProfile = onOpenUserProfile,
onJoinCall = onJoinCall,
onAudioPermissionPermanentlyDenied = {
permissionPermanentlyDeniedDialogState.show(
PermissionPermanentlyDeniedDialogState.Visible(
R.string.app_permission_dialog_title,
R.string.call_permission_dialog_description
is ConversationListState.NotPaginated -> {
when {
// when conversation list is not yet fetched, show loading indicator
state.isLoading -> loadingListContent(lazyListState)
// when there is at least one conversation in any folder
state.conversations.isNotEmpty() && state.conversations.any { it.value.isNotEmpty() } -> ConversationList(
lazyListState = lazyListState,
conversationListItems = state.conversations,
onOpenConversation = onOpenConversation,
onEditConversation = onEditConversationItem,
onOpenUserProfile = onOpenUserProfile,
onJoinCall = onJoinCall,
onAudioPermissionPermanentlyDenied = {
permissionPermanentlyDeniedDialogState.show(
PermissionPermanentlyDeniedDialogState.Visible(
R.string.app_permission_dialog_title,
R.string.call_permission_dialog_description
)
)
)
}
)
// when there is no conversation in any folder
searchBarState.isSearchActive -> SearchConversationsEmptyContent(onNewConversationClicked = onNewConversationClicked)
else -> emptyListContent(conversationListViewModel.conversationListState.domain)
}
)
// when there is no conversation in any folder
searchBarState.isSearchActive -> SearchConversationsEmptyContent(onNewConversationClicked = onNewConversationClicked)
else -> emptyListContent(state.domain)
}
}
}

Expand Down
Loading
Loading