diff --git a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt index 64848ef5..9f4e5b98 100644 --- a/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/ChatsListRepositoryImpl.kt @@ -197,6 +197,8 @@ class ChatsListRepositoryImpl( private val modelCache = SynchronizedLruMap(MODEL_CACHE_SIZE) private val invalidatedModels = ConcurrentHashMap.newKeySet() + @Volatile + private var invalidateAllModels = true private var lastList: List? = null private var lastListFolderId: Int = -1 @@ -293,7 +295,11 @@ class ChatsListRepositoryImpl( } private fun rebuildChatModels(limit: Int): List { - return listManager.rebuildChatList(limit, emptyList()) { chat, order, isPinned -> + if (!invalidateAllModels) { + rebuildVisibleModels(limit)?.let { return it } + } + + val rebuilt = listManager.rebuildChatList(limit, emptyList()) { chat, order, isPinned -> val cached = modelCache[chat.id] if (cached != null && cached.order == order && @@ -308,6 +314,37 @@ class ChatsListRepositoryImpl( } } } + invalidatedModels.clear() + invalidateAllModels = false + return rebuilt + } + + private fun rebuildVisibleModels(limit: Int): List? { + val previous = lastList ?: return null + if (lastListFolderId != activeFolderId) return null + if (invalidatedModels.isEmpty()) return previous + + val visibleIndexes = previous.mapIndexed { index, chat -> chat.id to index }.toMap() + val updated = previous.toMutableList() + + for (chatId in invalidatedModels.toList()) { + val index = visibleIndexes[chatId] ?: return null + val chat = cache.allChats[chatId] ?: return null + val position = cache.activeListPositions[chatId] ?: return null + val oldModel = previous[index] + if (oldModel.order != position.order || oldModel.isPinned != position.isPinned) { + return null + } + updated[index] = modelFactory.mapChatToModel(chat, position.order, position.isPinned).also { mapped -> + modelCache[chatId] = mapped + } + invalidatedModels.remove(chatId) + } + + if (updated.size > limit) { + return null + } + return updated } private fun shouldEmitList(folderId: Int, newList: List): Boolean { @@ -324,6 +361,7 @@ class ChatsListRepositoryImpl( private fun clearTransientState() { modelCache.clear() invalidatedModels.clear() + invalidateAllModels = true lastList = null lastListFolderId = -1 _chatListFlow.value = emptyList() @@ -332,6 +370,7 @@ class ChatsListRepositoryImpl( private fun triggerUpdate(chatId: Long? = null) { if (chatId == null) { + invalidateAllModels = true invalidatedModels.addAll(cache.activeListPositions.keys) } else { invalidatedModels.add(chatId) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt index 4b95d14d..dd059a11 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListComponent.kt @@ -1,12 +1,18 @@ package org.monogram.presentation.features.chats +import androidx.compose.runtime.Immutable import kotlinx.coroutines.flow.StateFlow import org.monogram.domain.models.* import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.core.util.AppPreferences interface ChatListComponent { - val state: StateFlow + val uiState: StateFlow + val foldersState: StateFlow + val chatsState: StateFlow + val selectionState: StateFlow + val searchState: StateFlow + val appPreferences: AppPreferences fun onChatClicked(id: Long) @@ -52,26 +58,14 @@ interface ChatListComponent { fun updateScrollPosition(folderId: Int, index: Int, offset: Int) - data class State( - val chatsByFolder: Map> = emptyMap(), - val folders: List = emptyList(), - val selectedFolderId: Int = -1, + @Immutable + data class UiState( val currentUser: UserModel? = null, - val isLoadingByFolder: Map = emptyMap(), - val selectedChatIds: Set = emptySet(), - val isSearchActive: Boolean = false, - val searchQuery: String = "", - val searchResults: List = emptyList(), - val globalSearchResults: List = emptyList(), - val messageSearchResults: List = emptyList(), - val searchHistory: List = emptyList(), val connectionStatus: ConnectionStatus = ConnectionStatus.Connected, val isArchivePinned: Boolean = true, val isArchiveAlwaysVisible: Boolean = false, val isForwarding: Boolean = false, - val canLoadMoreMessages: Boolean = false, val instantViewUrl: String? = null, - val activeChatId: Long? = null, val isProxyEnabled: Boolean = false, val attachMenuBots: List = emptyList(), val botWebAppUrl: String? = null, @@ -80,10 +74,38 @@ interface ChatListComponent { val webAppBotId: Long? = null, val webAppBotName: String? = null, val webViewUrl: String? = null, - val updateState: UpdateState = UpdateState.Idle, + val updateState: UpdateState = UpdateState.Idle + ) + + @Immutable + data class FoldersState( + val chatsByFolder: Map> = emptyMap(), + val folders: List = emptyList(), + val selectedFolderId: Int = -1, + val isLoadingByFolder: Map = emptyMap(), val scrollPositions: Map> = emptyMap() - ) { - val chats: List get() = chatsByFolder[selectedFolderId] ?: emptyList() - val isLoading: Boolean get() = isLoadingByFolder[selectedFolderId] ?: false - } + ) + + data class ChatsState( + val chats: List = emptyList(), + val isLoading: Boolean = false + ) + + @Immutable + data class SelectionState( + val selectedChatIds: Set = emptySet(), + val activeChatId: Long? = null + ) + + @Immutable + data class SearchState( + val isSearchActive: Boolean = false, + val searchQuery: String = "", + val searchResults: List = emptyList(), + val globalSearchResults: List = emptyList(), + val messageSearchResults: List = emptyList(), + val recentUsers: List = emptyList(), + val recentOthers: List = emptyList(), + val canLoadMoreMessages: Boolean = false + ) } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt deleted file mode 100644 index 6a894216..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStore.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.monogram.presentation.features.chats - -import com.arkivanov.mvikotlin.core.store.Store - -interface ChatListStore : Store { - - sealed class Intent { - data class ChatClicked(val id: Long) : Intent() - data class ProfileClicked(val id: Long) : Intent() - data class MessageClicked(val chatId: Long, val messageId: Long) : Intent() - object SettingsClicked : Intent() - data class FolderClicked(val id: Int) : Intent() - data class LoadMore(val folderId: Int? = null) : Intent() - object LoadMoreMessages : Intent() - data class ChatLongClicked(val id: Long) : Intent() - object ClearSelection : Intent() - object RetryConnection : Intent() - object SearchToggle : Intent() - data class SearchQueryChange(val query: String) : Intent() - object ClearSearchHistory : Intent() - data class RemoveSearchHistoryItem(val chatId: Long) : Intent() - data class MuteSelected(val mute: Boolean) : Intent() - data class ArchiveSelected(val archive: Boolean) : Intent() - object DeleteSelected : Intent() - object ArchivePinToggle : Intent() - object ConfirmForwarding : Intent() - object NewChatClicked : Intent() - object ProxySettingsClicked : Intent() - data class OpenInstantView(val url: String) : Intent() - object DismissInstantView : Intent() - data class OpenWebApp(val url: String, val botUserId: Long, val botName: String) : Intent() - object DismissWebApp : Intent() - data class OpenWebView(val url: String) : Intent() - object DismissWebView : Intent() - object UpdateClicked : Intent() - data class UpdateScrollPosition(val folderId: Int, val index: Int, val offset: Int) : Intent() - data class UpdateState(val state: ChatListComponent.State) : Intent() - } - - sealed class Label { - data class ChatClicked(val id: Long) : Label() - data class ProfileClicked(val id: Long) : Label() - data class MessageClicked(val chatId: Long, val messageId: Long) : Label() - object SettingsClicked : Label() - object NewChatClicked : Label() - object ProxySettingsClicked : Label() - } -} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt deleted file mode 100644 index 6f1e9485..00000000 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/ChatListStoreFactory.kt +++ /dev/null @@ -1,76 +0,0 @@ -package org.monogram.presentation.features.chats - -import com.arkivanov.mvikotlin.core.store.Reducer -import com.arkivanov.mvikotlin.core.store.Store -import com.arkivanov.mvikotlin.core.store.StoreFactory -import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor -import org.monogram.presentation.features.chats.ChatListStore.Intent -import org.monogram.presentation.features.chats.ChatListStore.Label -import org.monogram.presentation.features.chats.chatList.DefaultChatListComponent - -class ChatListStoreFactory( - private val storeFactory: StoreFactory, - private val component: DefaultChatListComponent -) { - - fun create(): ChatListStore = - object : ChatListStore, Store by storeFactory.create( - name = "ChatListStore", - initialState = ChatListComponent.State(isForwarding = component.isForwarding), - executorFactory = ::ExecutorImpl, - reducer = ReducerImpl - ) {} - - private inner class ExecutorImpl : CoroutineExecutor() { - override fun executeIntent(intent: Intent) { - when (intent) { - is Intent.ChatClicked -> component.onChatClicked(intent.id) - is Intent.ProfileClicked -> component.onProfileClicked(intent.id) - is Intent.MessageClicked -> component.onMessageClicked(intent.chatId, intent.messageId) - Intent.SettingsClicked -> component.onSettingsClicked() - is Intent.FolderClicked -> component.onFolderClicked(intent.id) - is Intent.LoadMore -> component.loadMore(intent.folderId) - Intent.LoadMoreMessages -> component.loadMoreMessages() - is Intent.ChatLongClicked -> component.onChatLongClicked(id = intent.id) - Intent.ClearSelection -> component.clearSelection() - Intent.RetryConnection -> component.retryConnection() - Intent.SearchToggle -> component.onSearchToggle() - is Intent.SearchQueryChange -> component.onSearchQueryChange(intent.query) - Intent.ClearSearchHistory -> component.onClearSearchHistory() - is Intent.RemoveSearchHistoryItem -> component.onRemoveSearchHistoryItem(intent.chatId) - is Intent.MuteSelected -> component.onMuteSelected(intent.mute) - is Intent.ArchiveSelected -> component.onArchiveSelected(archive = intent.archive) - Intent.DeleteSelected -> component.onDeleteSelected() - Intent.ArchivePinToggle -> component.onArchivePinToggle() - Intent.ConfirmForwarding -> component.onConfirmForwarding() - Intent.NewChatClicked -> component.onNewChatClicked() - Intent.ProxySettingsClicked -> component.onProxySettingsClicked() - is Intent.OpenInstantView -> component.onOpenInstantView(intent.url) - Intent.DismissInstantView -> component.onDismissInstantView() - is Intent.OpenWebApp -> component.onOpenWebApp(intent.url, intent.botUserId, intent.botName) - Intent.DismissWebApp -> component.onDismissWebApp() - is Intent.OpenWebView -> component.onOpenWebView(intent.url) - Intent.DismissWebView -> component.onDismissWebView() - Intent.UpdateClicked -> component.onUpdateClicked() - is Intent.UpdateScrollPosition -> component.updateScrollPosition( - intent.folderId, - intent.index, - intent.offset - ) - - is Intent.UpdateState -> dispatch(Message.UpdateState(intent.state)) - } - } - } - - private object ReducerImpl : Reducer { - override fun ChatListComponent.State.reduce(msg: Message): ChatListComponent.State = - when (msg) { - is Message.UpdateState -> msg.state - } - } - - sealed class Message { - data class UpdateState(val state: ChatListComponent.State) : Message() - } -} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt index 882033d1..896aa2a8 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt @@ -113,7 +113,6 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import org.koin.compose.koinInject -import org.monogram.domain.models.ChatType import org.monogram.domain.repository.ConnectionStatus import org.monogram.presentation.R import org.monogram.presentation.core.ui.Avatar @@ -140,7 +139,12 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun ChatListContent(component: ChatListComponent) { - val state by component.state.collectAsState() + val uiState by component.uiState.collectAsState() + val foldersState by component.foldersState.collectAsState() + val chatsState by component.chatsState.collectAsState() + val selectionState by component.selectionState.collectAsState() + val searchState by component.searchState.collectAsState() + val scope = rememberCoroutineScope() val haptic = LocalHapticFeedback.current @@ -164,7 +168,7 @@ fun ChatListContent(component: ChatListComponent) { adaptiveInfo.windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) && isTabletInterfaceEnabled val isCustomBackHandlingEnabled = - state.isSearchActive || state.selectedChatIds.isNotEmpty() || state.selectedFolderId == -2 || state.isForwarding || state.instantViewUrl != null || state.webAppUrl != null || state.webViewUrl != null || showStatusMenu + searchState.isSearchActive || selectionState.selectedChatIds.isNotEmpty() || foldersState.selectedFolderId == -2 || uiState.isForwarding || uiState.instantViewUrl != null || uiState.webAppUrl != null || uiState.webViewUrl != null || showStatusMenu BackHandler(enabled = isCustomBackHandlingEnabled) { if (showStatusMenu) { @@ -175,26 +179,26 @@ fun ChatListContent(component: ChatListComponent) { } val pagerState = rememberPagerState( - initialPage = state.folders.indexOfFirst { it.id == state.selectedFolderId }.coerceAtLeast(0), - pageCount = { state.folders.size } + initialPage = foldersState.folders.indexOfFirst { it.id == foldersState.selectedFolderId }.coerceAtLeast(0), + pageCount = { foldersState.folders.size } ) - LaunchedEffect(state.selectedFolderId) { - val index = state.folders.indexOfFirst { it.id == state.selectedFolderId }.coerceAtLeast(0) + LaunchedEffect(foldersState.selectedFolderId) { + val index = foldersState.folders.indexOfFirst { it.id == foldersState.selectedFolderId }.coerceAtLeast(0) if (pagerState.currentPage != index) pagerState.animateScrollToPage(index) } LaunchedEffect(pagerState.currentPage) { - if (state.folders.isNotEmpty()) { - val folderId = state.folders[pagerState.currentPage].id - if (state.selectedFolderId != folderId && state.selectedFolderId != -2) { + if (foldersState.folders.isNotEmpty()) { + val folderId = foldersState.folders[pagerState.currentPage].id + if (foldersState.selectedFolderId != folderId && foldersState.selectedFolderId != -2) { component.onFolderClicked(folderId) } } } val density = LocalDensity.current - val tabsHeight = if (state.folders.size > 1) 56.dp else 10.dp + val tabsHeight = if (foldersState.folders.size > 1) 56.dp else 10.dp val archiveItemHeight = 78.dp val tabsHeightPx = with(density) { tabsHeight.toPx() } val archiveItemHeightPx = with(density) { archiveItemHeight.toPx() } @@ -208,10 +212,10 @@ fun ChatListContent(component: ChatListComponent) { var hasVibrated by remember { mutableStateOf(false) } var canRevealArchive by remember { mutableStateOf(true) } - val currentFolder = state.folders.getOrNull(pagerState.currentPage) + val currentFolder = foldersState.folders.getOrNull(pagerState.currentPage) val isMainFolder = currentFolder?.id == -1 - val isArchivePersistent = state.isArchivePinned && (state.isArchiveAlwaysVisible || isMainFolder) + val isArchivePersistent = uiState.isArchivePinned && (uiState.isArchiveAlwaysVisible || isMainFolder) val canShowArchive = isArchivePersistent || isMainFolder val lastArchivePersistent = remember { mutableStateOf(isArchivePersistent) } @@ -263,7 +267,7 @@ fun ChatListContent(component: ChatListComponent) { } } - val nestedScrollConnection = remember(isArchivePersistent, canShowArchive, state.isArchiveAlwaysVisible, tabsHeightPx) { + val nestedScrollConnection = remember(isArchivePersistent, canShowArchive, uiState.isArchiveAlwaysVisible, tabsHeightPx) { object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { if (source == NestedScrollSource.UserInput) { @@ -308,7 +312,7 @@ fun ChatListContent(component: ChatListComponent) { } var limit = 0f - if (isArchivePersistent && !state.isArchiveAlwaysVisible) { + if (isArchivePersistent && !uiState.isArchiveAlwaysVisible) { limit = -archiveItemHeightPx } @@ -399,19 +403,19 @@ fun ChatListContent(component: ChatListComponent) { val isFabExpanded by remember { derivedStateOf { headerOffsetPx > -10f } } - var cachedStatusEmojiPath by remember(state.currentUser?.id) { - mutableStateOf(state.currentUser?.statusEmojiPath) + var cachedStatusEmojiPath by remember(uiState.currentUser?.id) { + mutableStateOf(uiState.currentUser?.statusEmojiPath) } - LaunchedEffect(state.currentUser?.id, state.currentUser?.statusEmojiPath) { - val statusEmojiPath = state.currentUser?.statusEmojiPath + LaunchedEffect(uiState.currentUser?.id, uiState.currentUser?.statusEmojiPath) { + val statusEmojiPath = uiState.currentUser?.statusEmojiPath if (!statusEmojiPath.isNullOrBlank()) { cachedStatusEmojiPath = statusEmojiPath } } - val currentUser = remember(state.currentUser, cachedStatusEmojiPath) { - state.currentUser?.let { user -> + val currentUser = remember(uiState.currentUser, cachedStatusEmojiPath) { + uiState.currentUser?.let { user -> if (user.statusEmojiId != 0L && user.statusEmojiPath.isNullOrBlank() && !cachedStatusEmojiPath.isNullOrBlank()) { user.copy(statusEmojiPath = cachedStatusEmojiPath) } else { @@ -423,7 +427,7 @@ fun ChatListContent(component: ChatListComponent) { if (showAccountMenu) { AccountMenu( user = currentUser, - attachMenuBots = state.attachMenuBots, + attachMenuBots = uiState.attachMenuBots, onDismiss = { showAccountMenu = false }, onSavedMessagesClick = { currentUser?.id?.let { component.onChatClicked(it) } @@ -436,13 +440,13 @@ fun ChatListContent(component: ChatListComponent) { onProfileClick = { currentUser?.id?.let { component.onProfileClicked(it) } }, - updateState = state.updateState, + updateState = uiState.updateState, onUpdateClick = { component.onUpdateClicked() }, onBotClick = { bot -> component.onOpenWebApp( - url = state.botWebAppUrl ?: "", + url = uiState.botWebAppUrl ?: "", botUserId = bot.botUserId, - botName = state.botWebAppName ?: bot.name + botName = uiState.botWebAppName ?: bot.name ) } ) @@ -478,19 +482,19 @@ fun ChatListContent(component: ChatListComponent) { topBar = { Column(Modifier.fillMaxWidth()) { AnimatedContent( - targetState = state.selectedChatIds.isNotEmpty() && !state.isForwarding, + targetState = selectionState.selectedChatIds.isNotEmpty() && !uiState.isForwarding, label = "TopBarSelectionAnimation", transitionSpec = { fadeIn() togetherWith fadeOut() } ) { isSelectionMode -> if (isSelectionMode) { - val selectedChats = state.chats.filter { state.selectedChatIds.contains(it.id) } + val selectedChats = chatsState.chats.filter { selectionState.selectedChatIds.contains(it.id) } val canMarkUnread = selectedChats.any { !it.isMarkedAsUnread } val allPinned = selectedChats.isNotEmpty() && selectedChats.all { it.isPinned } val allMuted = selectedChats.isNotEmpty() && selectedChats.all { it.isMuted } - val isInArchive = state.selectedFolderId == -2 + val isInArchive = foldersState.selectedFolderId == -2 SelectionTopBar( - selectedCount = state.selectedChatIds.size, + selectedCount = selectionState.selectedChatIds.size, isInArchive = isInArchive, allPinned = allPinned, allMuted = allMuted, @@ -503,7 +507,7 @@ fun ChatListContent(component: ChatListComponent) { canMarkUnread = canMarkUnread ) } else { - if (state.isForwarding) { + if (uiState.isForwarding) { TopAppBar( title = { Column { @@ -512,11 +516,11 @@ fun ChatListContent(component: ChatListComponent) { fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleMedium ) - if (state.selectedChatIds.isNotEmpty()) { + if (selectionState.selectedChatIds.isNotEmpty()) { Text( text = stringResource( R.string.chats_selected_format, - state.selectedChatIds.size + selectionState.selectedChatIds.size ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary @@ -530,7 +534,7 @@ fun ChatListContent(component: ChatListComponent) { } }, actions = { - if (state.selectedChatIds.isNotEmpty()) { + if (selectionState.selectedChatIds.isNotEmpty()) { IconButton(onClick = { component.onConfirmForwarding() }) { Icon( Icons.AutoMirrored.Rounded.Send, @@ -542,7 +546,7 @@ fun ChatListContent(component: ChatListComponent) { }, colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow) ) - } else if (state.selectedFolderId == -2 && !state.isSearchActive) { + } else if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) { TopAppBar( title = { Text( @@ -568,12 +572,12 @@ fun ChatListContent(component: ChatListComponent) { } else { ChatListTopBar( user = currentUser, - connectionStatus = state.connectionStatus, - isProxyEnabled = state.isProxyEnabled, + connectionStatus = uiState.connectionStatus, + isProxyEnabled = uiState.isProxyEnabled, onRetryConnection = { component.retryConnection() }, onProxySettingsClick = { component.onProxySettingsClicked() }, - isSearchActive = state.isSearchActive, - searchQuery = state.searchQuery, + isSearchActive = searchState.isSearchActive, + searchQuery = searchState.searchQuery, onSearchQueryChange = component::onSearchQueryChange, onSearchToggle = component::onSearchToggle, onStatusClick = { anchorBounds -> @@ -586,7 +590,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.connectionStatus == ConnectionStatus.Connecting || state.connectionStatus == ConnectionStatus.Updating || state.connectionStatus == ConnectionStatus.ConnectingToProxy) { + if (uiState.connectionStatus == ConnectionStatus.Connecting || uiState.connectionStatus == ConnectionStatus.Updating || uiState.connectionStatus == ConnectionStatus.ConnectingToProxy) { Column { LinearWavyProgressIndicator( modifier = Modifier @@ -598,7 +602,7 @@ fun ChatListContent(component: ChatListComponent) { } } - val isMainView = !state.isSearchActive && state.selectedFolderId != -2 + val isMainView = !searchState.isSearchActive && foldersState.selectedFolderId != -2 if (isMainView) { Box( @@ -656,7 +660,7 @@ fun ChatListContent(component: ChatListComponent) { } ) { ArchiveHeaderCard( - isPinned = state.isArchivePinned, + isPinned = uiState.isArchivePinned, onClick = { component.onFolderClicked(-2) }, onLongClick = { component.onArchivePinToggle() } ) @@ -664,14 +668,14 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.folders.size > 1) { + if (foldersState.folders.size > 1) { FolderTabs( modifier = Modifier, - folders = state.folders, + folders = foldersState.folders, pagerState = pagerState, onTabClick = { index -> if (pagerState.currentPage == index) { - val folderId = state.folders[index].id + val folderId = foldersState.folders[index].id scope.launch { scrollStates[folderId]?.animateScrollToItem(0) } @@ -695,7 +699,7 @@ fun ChatListContent(component: ChatListComponent) { floatingActionButton = { if (!isTablet) { AnimatedVisibility( - visible = !state.isSearchActive && state.selectedFolderId != -2 && !state.isForwarding, + visible = !searchState.isSearchActive && foldersState.selectedFolderId != -2 && !uiState.isForwarding, enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut() ) { @@ -710,7 +714,7 @@ fun ChatListContent(component: ChatListComponent) { } AnimatedVisibility( - visible = state.isForwarding && state.selectedChatIds.isNotEmpty(), + visible = uiState.isForwarding && selectionState.selectedChatIds.isNotEmpty(), enter = scaleIn() + fadeIn(), exit = scaleOut() + fadeOut() ) { @@ -737,32 +741,32 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier .fillMaxSize() ) { - if (state.isSearchActive || state.selectedFolderId == -2) { + if (searchState.isSearchActive || foldersState.selectedFolderId == -2) { var showAllGlobal by remember { mutableStateOf(false) } var showAllMessages by remember { mutableStateOf(false) } val scrollState = rememberLazyListState( - initialFirstVisibleItemIndex = if (state.selectedFolderId == -2 && !state.isSearchActive) state.scrollPositions[-2]?.first ?: 0 else 0, - initialFirstVisibleItemScrollOffset = if (state.selectedFolderId == -2 && !state.isSearchActive) state.scrollPositions[-2]?.second ?: 0 else 0 + initialFirstVisibleItemIndex = if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) foldersState.scrollPositions[-2]?.first ?: 0 else 0, + initialFirstVisibleItemScrollOffset = if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) foldersState.scrollPositions[-2]?.second ?: 0 else 0 ) - if (state.selectedFolderId == -2 && !state.isSearchActive) { + if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) { scrollStates[-2] = scrollState } - val firstItemId = if (state.selectedFolderId == -2 && !state.isSearchActive) { - state.chatsByFolder[-2]?.firstOrNull()?.id + val firstItemId = if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) { + foldersState.chatsByFolder[-2]?.firstOrNull()?.id } else { null } LaunchedEffect(firstItemId) { - if (state.selectedFolderId == -2 && !state.isSearchActive && !scrollState.isScrollInProgress && scrollState.firstVisibleItemIndex <= 1) { + if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive && !scrollState.isScrollInProgress && scrollState.firstVisibleItemIndex <= 1) { scrollState.scrollToItem(0, 0) } } - if (state.selectedFolderId == -2 && !state.isSearchActive) { + if (foldersState.selectedFolderId == -2 && !searchState.isSearchActive) { DisposableEffect(Unit) { onDispose { component.updateScrollPosition(-2, scrollState.firstVisibleItemIndex, scrollState.firstVisibleItemScrollOffset) @@ -770,10 +774,10 @@ fun ChatListContent(component: ChatListComponent) { } } - val isArchivedView = state.selectedFolderId == -2 && !state.isSearchActive - val archivedChats = if (isArchivedView) state.chatsByFolder[-2] ?: emptyList() else emptyList() - val isArchivedLoading = if (isArchivedView) state.isLoadingByFolder[-2] ?: false else false - val hasArchivedLoadState = if (isArchivedView) state.isLoadingByFolder.containsKey(-2) else false + val isArchivedView = foldersState.selectedFolderId == -2 && !searchState.isSearchActive + val archivedChats = if (isArchivedView) chatsState.chats else emptyList() + val isArchivedLoading = if (isArchivedView) chatsState.isLoading else false + val hasArchivedLoadState = isArchivedView && (foldersState.isLoadingByFolder.containsKey(-2) || chatsState.chats.isNotEmpty()) val showArchivedShimmer = isArchivedView && archivedChats.isEmpty() && (isArchivedLoading || !hasArchivedLoadState) val shouldAnimateFirstArchiveTransition = firstFolderTransitionCompleted[-2] != true @@ -814,13 +818,8 @@ fun ChatListContent(component: ChatListComponent) { end = if (isTablet) 12.dp else 0.dp ), ) { - if (state.isSearchActive) { - if (state.searchQuery.isEmpty() && state.searchHistory.isNotEmpty()) { - val recentUsers = - state.searchHistory.filter { (it.type == ChatType.PRIVATE || it.type == ChatType.SECRET) && !it.isBot } - val recentOthers = - state.searchHistory.filter { it.type != ChatType.PRIVATE && it.type != ChatType.SECRET || it.isBot } - + if (searchState.isSearchActive) { + if (searchState.searchQuery.isEmpty() && (searchState.recentUsers.isNotEmpty() || searchState.recentOthers.isNotEmpty())) { item { Row( modifier = Modifier @@ -841,7 +840,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (recentUsers.isNotEmpty()) { + if (searchState.recentUsers.isNotEmpty()) { item { LazyRow( modifier = Modifier @@ -850,7 +849,7 @@ fun ChatListContent(component: ChatListComponent) { contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - itemsIndexed(items = recentUsers, key = { index, chat -> "recent_user_${chat.id}_$index" }) { _, chat -> + itemsIndexed(items = searchState.recentUsers, key = { index, chat -> "recent_user_${chat.id}_$index" }) { _, chat -> Column( modifier = Modifier .width(64.dp) @@ -908,18 +907,18 @@ fun ChatListContent(component: ChatListComponent) { } } - if (recentOthers.isNotEmpty()) { + if (searchState.recentOthers.isNotEmpty()) { itemsIndexed( - items = recentOthers, + items = searchState.recentOthers, key = { _, chat -> "recent_${chat.id}" }) { _, chat -> ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, + currentUserId = uiState.currentUser?.id, isSelected = false, onClick = { onChatClicked(chat.id) }, onLongClick = { component.onRemoveSearchHistoryItem(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -928,7 +927,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.searchResults.isNotEmpty()) { + if (searchState.searchResults.isNotEmpty()) { item { Text( text = stringResource(R.string.search_section_chats), @@ -937,15 +936,15 @@ fun ChatListContent(component: ChatListComponent) { color = MaterialTheme.colorScheme.primary ) } - itemsIndexed(items = state.searchResults, key = { index, chat -> "search_${chat.id}_$index" }) { _, chat -> + itemsIndexed(items = searchState.searchResults, key = { index, chat -> "search_${chat.id}_$index" }) { _, chat -> ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, - isSelected = state.selectedChatIds.contains(chat.id), + currentUserId = uiState.currentUser?.id, + isSelected = selectionState.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -953,7 +952,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.globalSearchResults.isNotEmpty()) { + if (searchState.globalSearchResults.isNotEmpty()) { item { Text( text = stringResource(R.string.search_section_global), @@ -964,24 +963,24 @@ fun ChatListContent(component: ChatListComponent) { } val globalToDisplay = - if (showAllGlobal) state.globalSearchResults else state.globalSearchResults.take(3) + if (showAllGlobal) searchState.globalSearchResults else searchState.globalSearchResults.take(3) itemsIndexed(items = globalToDisplay, key = { _, chat -> "global_${chat.id}" }) { _, chat -> ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, - isSelected = state.selectedChatIds.contains(chat.id), + currentUserId = uiState.currentUser?.id, + isSelected = selectionState.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos ) } - if (!showAllGlobal && state.globalSearchResults.size > 3) { + if (!showAllGlobal && searchState.globalSearchResults.size > 3) { item { Box( modifier = Modifier @@ -1000,7 +999,7 @@ fun ChatListContent(component: ChatListComponent) { } } - if (state.messageSearchResults.isNotEmpty()) { + if (searchState.messageSearchResults.isNotEmpty()) { item { Text( text = stringResource(R.string.search_section_messages), @@ -1011,12 +1010,12 @@ fun ChatListContent(component: ChatListComponent) { } val messagesToDisplay = - if (showAllMessages) state.messageSearchResults else state.messageSearchResults.take(3) + if (showAllMessages) searchState.messageSearchResults else searchState.messageSearchResults.take(3) itemsIndexed( items = messagesToDisplay, key = { index, msg -> "msg_${msg.id}_$index" }) { index, msg -> - if (showAllMessages && index >= messagesToDisplay.lastIndex - 5 && state.canLoadMoreMessages) { + if (showAllMessages && index >= messagesToDisplay.lastIndex - 5 && searchState.canLoadMoreMessages) { LaunchedEffect(Unit) { component.loadMoreMessages() } } @@ -1027,7 +1026,7 @@ fun ChatListContent(component: ChatListComponent) { ) } - if (!showAllMessages && state.messageSearchResults.size > 3) { + if (!showAllMessages && searchState.messageSearchResults.size > 3) { item { Box( modifier = Modifier @@ -1056,11 +1055,11 @@ fun ChatListContent(component: ChatListComponent) { ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, - isSelected = state.selectedChatIds.contains(chat.id), + currentUserId = uiState.currentUser?.id, + isSelected = selectionState.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -1087,21 +1086,21 @@ fun ChatListContent(component: ChatListComponent) { modifier = Modifier.fillMaxSize(), beyondViewportPageCount = 1 ) { page -> - val folderId = state.folders.getOrNull(page)?.id - ?: state.folders.firstOrNull { it.id == state.selectedFolderId }?.id + val folderId = foldersState.folders.getOrNull(page)?.id + ?: foldersState.folders.firstOrNull { it.id == foldersState.selectedFolderId }?.id if (folderId == null) { Box(modifier = Modifier.fillMaxSize()) return@HorizontalPager } - val folderChats = state.chatsByFolder[folderId] ?: emptyList() - val isFolderLoading = state.isLoadingByFolder[folderId] ?: false - val hasFolderLoadState = state.isLoadingByFolder.containsKey(folderId) + val folderChats = foldersState.chatsByFolder[folderId] ?: emptyList() + val isFolderLoading = foldersState.isLoadingByFolder[folderId] ?: false + val hasFolderLoadState = foldersState.isLoadingByFolder.containsKey(folderId) val showFolderShimmer = folderChats.isEmpty() && (isFolderLoading || !hasFolderLoadState) val shouldAnimateFirstFolderTransition = firstFolderTransitionCompleted[folderId] != true val scrollState = rememberLazyListState( - initialFirstVisibleItemIndex = state.scrollPositions[folderId]?.first ?: 0, - initialFirstVisibleItemScrollOffset = state.scrollPositions[folderId]?.second ?: 0 + initialFirstVisibleItemIndex = foldersState.scrollPositions[folderId]?.first ?: 0, + initialFirstVisibleItemScrollOffset = foldersState.scrollPositions[folderId]?.second ?: 0 ) scrollStates[folderId] = scrollState @@ -1134,7 +1133,7 @@ fun ChatListContent(component: ChatListComponent) { val isInitialLoad = remember(folderId) { mutableStateOf(true) } LaunchedEffect(folderChats) { if (isInitialLoad.value && folderChats.isNotEmpty()) { - if (state.scrollPositions[folderId] == null) { + if (foldersState.scrollPositions[folderId] == null) { scrollState.scrollToItem(0, 0) } isInitialLoad.value = false @@ -1187,11 +1186,11 @@ fun ChatListContent(component: ChatListComponent) { ChatListItem( modifier = Modifier.animateItem(), chat = chat, - currentUserId = state.currentUser?.id, - isSelected = state.selectedChatIds.contains(chat.id), + currentUserId = uiState.currentUser?.id, + isSelected = selectionState.selectedChatIds.contains(chat.id), onClick = { onChatClicked(chat.id) }, onLongClick = { onChatLongClicked(chat.id) }, - isTabletSelected = isTablet && state.activeChatId == chat.id, + isTabletSelected = isTablet && selectionState.activeChatId == chat.id, emojiFontFamily = emojiFontFamily, messageLines = messageLines, showPhotos = showPhotos @@ -1308,11 +1307,11 @@ fun ChatListContent(component: ChatListComponent) { } AnimatedVisibility( - visible = state.instantViewUrl != null, + visible = uiState.instantViewUrl != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { - state.instantViewUrl?.let { url -> + uiState.instantViewUrl?.let { url -> InstantViewer( url = url, messageRepository = koinInject(), @@ -1324,13 +1323,13 @@ fun ChatListContent(component: ChatListComponent) { } AnimatedVisibility( - visible = state.webAppUrl != null || state.webAppBotId != null, + visible = uiState.webAppUrl != null || uiState.webAppBotId != null, enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() ) { - val webAppUrl = state.webAppUrl - val botUserId = state.webAppBotId - val botName = state.webAppBotName + val webAppUrl = uiState.webAppUrl + val botUserId = uiState.webAppBotId + val botName = uiState.webAppBotName Log.d("MiniAppViewer", "webAppUrl: $webAppUrl, botUserId: $botUserId, botName: $botName") @@ -1349,7 +1348,7 @@ fun ChatListContent(component: ChatListComponent) { if (showDeleteChatsSheet) { ConfirmationSheet( icon = Icons.Rounded.Delete, - title = stringResource(R.string.delete_chats_title, state.selectedChatIds.size), + title = stringResource(R.string.delete_chats_title, selectionState.selectedChatIds.size), description = stringResource(R.string.delete_chats_confirmation), confirmText = stringResource(R.string.action_delete_chats), onConfirm = { diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt index b1bef737..c2e63947 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/DefaultChatListComponent.kt @@ -2,23 +2,19 @@ package org.monogram.presentation.features.chats.chatList import android.util.Log import com.arkivanov.decompose.value.Value -import com.arkivanov.mvikotlin.core.instancekeeper.getStore -import com.arkivanov.mvikotlin.extensions.coroutines.stateFlow -import com.arkivanov.mvikotlin.main.store.DefaultStoreFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import org.monogram.domain.models.BotMenuButtonModel +import org.monogram.domain.models.ChatType import org.monogram.domain.models.UpdateState import org.monogram.domain.repository.* import org.monogram.presentation.core.util.AppPreferences import org.monogram.presentation.core.util.coRunCatching import org.monogram.presentation.core.util.componentScope import org.monogram.presentation.features.chats.ChatListComponent -import org.monogram.presentation.features.chats.ChatListStore -import org.monogram.presentation.features.chats.ChatListStoreFactory import org.monogram.presentation.root.AppComponentContext class DefaultChatListComponent( @@ -45,21 +41,17 @@ class DefaultChatListComponent( private val updateRepository: UpdateRepository = container.repositories.updateRepository override val appPreferences: AppPreferences = container.preferences.appPreferences - private val _state = MutableStateFlow( - ChatListComponent.State( - isForwarding = isForwarding, - isLoadingByFolder = mapOf(-1 to true) - ) - ) - - private val store = instanceKeeper.getStore { - ChatListStoreFactory( - storeFactory = DefaultStoreFactory(), - component = this - ).create() - } + private val _uiState = MutableStateFlow(ChatListComponent.UiState(isForwarding = isForwarding)) + private val _foldersState = MutableStateFlow(ChatListComponent.FoldersState(isLoadingByFolder = mapOf(-1 to true))) + private val _chatsState = MutableStateFlow(ChatListComponent.ChatsState()) + private val _selectionState = MutableStateFlow(ChatListComponent.SelectionState()) + private val _searchState = MutableStateFlow(ChatListComponent.SearchState()) - override val state: StateFlow = store.stateFlow + override val uiState: StateFlow = _uiState.asStateFlow() + override val foldersState: StateFlow = _foldersState.asStateFlow() + override val chatsState: StateFlow = _chatsState + override val selectionState: StateFlow = _selectionState.asStateFlow() + override val searchState: StateFlow = _searchState.asStateFlow() private val scope = componentScope private var searchJob: Job? = null @@ -68,13 +60,13 @@ class DefaultChatListComponent( init { activeChatId.subscribe { id -> - _state.update { it.copy(activeChatId = id) } + _selectionState.update { it.copy(activeChatId = id) } } repositoryUser.currentUserFlow .onEach { user -> if (user != null) { - _state.update { it.copy(currentUser = user) } + _uiState.update { it.copy(currentUser = user) } } } .launchIn(scope) @@ -95,70 +87,90 @@ class DefaultChatListComponent( }" ) } - _state.update { + _foldersState.update { val newChatsByFolder = it.chatsByFolder.toMutableMap() newChatsByFolder[update.folderId] = distinctList it.copy(chatsByFolder = newChatsByFolder) } + + if (update.folderId == _foldersState.value.selectedFolderId) { + _chatsState.update { + it.copy(chats = distinctList) + } + } } .launchIn(scope) chatFolderRepository.foldersFlow .onEach { folders -> - _state.update { it.copy(folders = folders) } + _foldersState.update { it.copy(folders = folders) } } .launchIn(scope) chatFolderRepository.folderLoadingFlow .onEach { update -> - _state.update { + _foldersState.update { val newLoadingByFolder = it.isLoadingByFolder.toMutableMap() newLoadingByFolder[update.folderId] = update.isLoading it.copy(isLoadingByFolder = newLoadingByFolder) } + if (update.folderId == _foldersState.value.selectedFolderId) { + _chatsState.update { + it.copy(isLoading = update.isLoading) + } + } } .launchIn(scope) chatListRepository.connectionStateFlow .onEach { status -> - _state.update { it.copy(connectionStatus = status) } + _uiState.update { it.copy(connectionStatus = status) } } .launchIn(scope) appPreferences.enabledProxyId .onEach { enabledProxyId -> - _state.update { it.copy(isProxyEnabled = enabledProxyId != null) } + _uiState.update { it.copy(isProxyEnabled = enabledProxyId != null) } } .launchIn(scope) chatOperationsRepository.isArchivePinned .onEach { isPinned -> - _state.update { it.copy(isArchivePinned = isPinned) } + _uiState.update { it.copy(isArchivePinned = isPinned) } } .launchIn(scope) chatOperationsRepository.isArchiveAlwaysVisible .onEach { alwaysVisible -> - _state.update { it.copy(isArchiveAlwaysVisible = alwaysVisible) } + _uiState.update { it.copy(isArchiveAlwaysVisible = alwaysVisible) } } .launchIn(scope) chatSearchRepository.searchHistory .onEach { history -> - _state.update { it.copy(searchHistory = history) } + _searchState.update { + it.copy( + recentUsers = history.filter { chat -> + (chat.type == ChatType.PRIVATE || chat.type == ChatType.SECRET) && !chat.isBot + }, + recentOthers = history.filter { chat -> + chat.type != ChatType.PRIVATE && chat.type != ChatType.SECRET || chat.isBot + } + ) + } } .launchIn(scope) attachMenuBotRepository.getAttachMenuBots() .onEach { bots -> - _state.update { it.copy(attachMenuBots = bots) } + _uiState.update { it.copy(attachMenuBots = bots) } bots.firstOrNull()?.let { bot -> if (bot.botUserId != 0L) { val botInfo = botRepository.getBotInfo(bot.botUserId) val menuButton = botInfo?.menuButton if (menuButton is BotMenuButtonModel.WebApp) { - _state.update { + _uiState.update { it.copy( botWebAppUrl = menuButton.url, botWebAppName = menuButton.text @@ -172,7 +184,7 @@ class DefaultChatListComponent( updateRepository.updateState .onEach { updateState -> - _state.update { it.copy(updateState = updateState) } + _uiState.update { it.copy(updateState = updateState) } } .launchIn(scope) @@ -180,12 +192,8 @@ class DefaultChatListComponent( updateRepository.checkForUpdates() } - _state.onEach { - store.accept(ChatListStore.Intent.UpdateState(it)) - }.launchIn(scope) - scope.launch(Dispatchers.IO) { - chatListRepository.selectFolder(_state.value.selectedFolderId) + chatListRepository.selectFolder(_foldersState.value.selectedFolderId) } } @@ -194,9 +202,9 @@ class DefaultChatListComponent( } override fun onFolderClicked(id: Int) { - if (_state.value.selectedFolderId == id) return + if (_foldersState.value.selectedFolderId == id) return - _state.update { + _foldersState.update { val loadingByFolder = it.isLoadingByFolder.toMutableMap() loadingByFolder[id] = true it.copy( @@ -205,17 +213,22 @@ class DefaultChatListComponent( ) } + _chatsState.value = ChatListComponent.ChatsState( + chats = _foldersState.value.chatsByFolder[id].orEmpty(), + isLoading = true + ) + scope.launch(Dispatchers.IO) { chatListRepository.selectFolder(id) } } override fun loadMore(folderId: Int?) { - val targetFolderId = folderId ?: _state.value.selectedFolderId - if (_state.value.isLoadingByFolder[targetFolderId] == true) return + val targetFolderId = folderId ?: _foldersState.value.selectedFolderId + if (_foldersState.value.isLoadingByFolder[targetFolderId] == true) return scope.launch(Dispatchers.IO) { - if (folderId != null && folderId != _state.value.selectedFolderId) { + if (folderId != null && folderId != _foldersState.value.selectedFolderId) { return@launch } chatListRepository.loadNextChunk(20) @@ -226,11 +239,11 @@ class DefaultChatListComponent( if (isFetchingMoreMessages || nextMessagesOffset.isEmpty()) return isFetchingMoreMessages = true - val query = _state.value.searchQuery + val query = _searchState.value.searchQuery scope.launch(Dispatchers.IO) { val result = chatSearchRepository.searchMessages(query, offset = nextMessagesOffset) nextMessagesOffset = result.nextOffset - _state.update { + _searchState.update { it.copy( messageSearchResults = it.messageSearchResults + result.messages, canLoadMoreMessages = nextMessagesOffset.isNotEmpty() @@ -241,12 +254,12 @@ class DefaultChatListComponent( } override fun onChatClicked(id: Long) { - if (_state.value.isForwarding) { + if (_uiState.value.isForwarding) { toggleSelection(id) - } else if (_state.value.selectedChatIds.isNotEmpty()) { + } else if (_selectionState.value.selectedChatIds.isNotEmpty()) { toggleSelection(id) } else { - if (_state.value.isSearchActive) { + if (_searchState.value.isSearchActive) { chatSearchRepository.addSearchChatId(id) } onSelect(id, null) @@ -258,12 +271,12 @@ class DefaultChatListComponent( } override fun onMessageClicked(chatId: Long, messageId: Long) { - if (_state.value.isForwarding) { + if (_uiState.value.isForwarding) { toggleSelection(chatId) - } else if (_state.value.selectedChatIds.isNotEmpty()) { + } else if (_selectionState.value.selectedChatIds.isNotEmpty()) { toggleSelection(chatId) } else { - if (_state.value.isSearchActive) { + if (_searchState.value.isSearchActive) { chatSearchRepository.addSearchChatId(chatId) } onSelect(chatId, messageId) @@ -275,7 +288,7 @@ class DefaultChatListComponent( } override fun clearSelection() { - _state.update { it.copy(selectedChatIds = emptySet()) } + _selectionState.update { it.copy(selectedChatIds = emptySet()) } } override fun onSettingsClicked() { @@ -283,9 +296,10 @@ class DefaultChatListComponent( } override fun onSearchToggle() { - _state.update { + val isSearchActive = !_searchState.value.isSearchActive + _searchState.update { it.copy( - isSearchActive = !it.isSearchActive, + isSearchActive = isSearchActive, searchQuery = "", searchResults = emptyList(), globalSearchResults = emptyList(), @@ -297,22 +311,23 @@ class DefaultChatListComponent( } override fun onSearchQueryChange(query: String) { - _state.update { it.copy(searchQuery = query) } + _searchState.update { it.copy(searchQuery = query) } searchJob?.cancel() searchJob = scope.launch(Dispatchers.IO) { delay(300) if (query.isNotEmpty()) { - if (_state.value.selectedFolderId == -2) { - val archivedChats = _state.value.chatsByFolder[-2].orEmpty() + if (_foldersState.value.selectedFolderId == -2) { + val archivedChats = _foldersState.value.chatsByFolder[-2].orEmpty() val trimmedQuery = query.trim() val archiveResults = archivedChats.filter { chat -> chat.title.contains(trimmedQuery, ignoreCase = true) || chat.lastMessageText.contains(trimmedQuery, ignoreCase = true) } - _state.update { + _searchState.update { it.copy( + searchQuery = query, searchResults = archiveResults, globalSearchResults = emptyList(), messageSearchResults = emptyList(), @@ -324,14 +339,14 @@ class DefaultChatListComponent( } val localResults = chatSearchRepository.searchChats(query) - _state.update { it.copy(searchResults = localResults) } + _searchState.update { it.copy(searchResults = localResults) } val globalResults = chatSearchRepository.searchPublicChats(query) - _state.update { it.copy(globalSearchResults = globalResults) } + _searchState.update { it.copy(globalSearchResults = globalResults) } val messageResults = chatSearchRepository.searchMessages(query) nextMessagesOffset = messageResults.nextOffset - _state.update { + _searchState.update { it.copy( messageSearchResults = messageResults.messages, canLoadMoreMessages = nextMessagesOffset.isNotEmpty() @@ -339,8 +354,9 @@ class DefaultChatListComponent( } } else { nextMessagesOffset = "" - _state.update { + _searchState.update { it.copy( + searchQuery = "", searchResults = emptyList(), globalSearchResults = emptyList(), messageSearchResults = emptyList(), @@ -352,7 +368,7 @@ class DefaultChatListComponent( } override fun onSetEmojiStatus(customEmojiId: Long, statusPath: String?) { - _state.update { state -> + _uiState.update { state -> val user = state.currentUser ?: return@update state state.copy( currentUser = user.copy( @@ -378,8 +394,8 @@ class DefaultChatListComponent( } override fun onMuteSelected(mute: Boolean) { - val selectedIds = _state.value.selectedChatIds - val selectedChats = _state.value.chats.filter { selectedIds.contains(it.id) } + val selectedIds = _selectionState.value.selectedChatIds + val selectedChats = _chatsState.value.chats.filter { selectedIds.contains(it.id) } val shouldMute = selectedChats.any { !it.isMuted } scope.launch(Dispatchers.IO) { @@ -389,7 +405,7 @@ class DefaultChatListComponent( } override fun onArchiveSelected(archive: Boolean) { - val selectedIds = _state.value.selectedChatIds + val selectedIds = _selectionState.value.selectedChatIds scope.launch(Dispatchers.IO) { chatOperationsRepository.toggleArchiveChats(selectedIds, archive) clearSelection() @@ -397,10 +413,10 @@ class DefaultChatListComponent( } override fun onPinSelected() { - val selectedIds = _state.value.selectedChatIds - val selectedChats = _state.value.chats.filter { selectedIds.contains(it.id) } + val selectedIds = _selectionState.value.selectedChatIds + val selectedChats = _chatsState.value.chats.filter { selectedIds.contains(it.id) } val shouldPin = selectedChats.any { !it.isPinned } - val folderId = _state.value.selectedFolderId + val folderId = _foldersState.value.selectedFolderId scope.launch(Dispatchers.IO) { chatOperationsRepository.togglePinChats(selectedIds, shouldPin, folderId) @@ -409,8 +425,8 @@ class DefaultChatListComponent( } override fun onToggleReadSelected() { - val selectedIds = _state.value.selectedChatIds - val selectedChats = _state.value.chats.filter { selectedIds.contains(it.id) } + val selectedIds = _selectionState.value.selectedChatIds + val selectedChats = _chatsState.value.chats.filter { selectedIds.contains(it.id) } val shouldMarkUnread = selectedChats.any { !it.isMarkedAsUnread } scope.launch(Dispatchers.IO) { @@ -420,7 +436,7 @@ class DefaultChatListComponent( } override fun onDeleteSelected() { - val selectedIds = _state.value.selectedChatIds + val selectedIds = _selectionState.value.selectedChatIds scope.launch(Dispatchers.IO) { chatOperationsRepository.deleteChats(selectedIds) clearSelection() @@ -428,12 +444,13 @@ class DefaultChatListComponent( } override fun onArchivePinToggle() { - chatOperationsRepository.setArchivePinned(!_state.value.isArchivePinned) + chatOperationsRepository.setArchivePinned(!_uiState.value.isArchivePinned) } override fun onConfirmForwarding() { - if (_state.value.selectedChatIds.isNotEmpty()) { - onConfirmForward(_state.value.selectedChatIds) + val selectedChatIds = _selectionState.value.selectedChatIds + if (selectedChatIds.isNotEmpty()) { + onConfirmForward(selectedChatIds) } } @@ -454,7 +471,7 @@ class DefaultChatListComponent( scope.launch(Dispatchers.IO) { chatFolderRepository.deleteFolder(folderId) - if (_state.value.selectedFolderId == folderId) { + if (_foldersState.value.selectedFolderId == folderId) { onFolderClicked(-1) } } @@ -466,15 +483,15 @@ class DefaultChatListComponent( } override fun onOpenInstantView(url: String) { - _state.update { it.copy(instantViewUrl = url) } + _uiState.update { it.copy(instantViewUrl = url) } } override fun onDismissInstantView() { - _state.update { it.copy(instantViewUrl = null) } + _uiState.update { it.copy(instantViewUrl = null) } } override fun onOpenWebApp(url: String, botUserId: Long, botName: String) { - _state.update { + _uiState.update { it.copy( webAppUrl = url, webAppBotId = botUserId, @@ -484,7 +501,7 @@ class DefaultChatListComponent( } override fun onDismissWebApp() { - _state.update { + _uiState.update { it.copy( webAppUrl = null, webAppBotId = null, @@ -494,15 +511,15 @@ class DefaultChatListComponent( } override fun onOpenWebView(url: String) { - _state.update { it.copy(webViewUrl = url) } + _uiState.update { it.copy(webViewUrl = url) } } override fun onDismissWebView() { - _state.update { it.copy(webViewUrl = null) } + _uiState.update { it.copy(webViewUrl = null) } } override fun onUpdateClicked() { - val currentState = _state.value.updateState + val currentState = _uiState.value.updateState when (currentState) { is UpdateState.UpdateAvailable -> { updateRepository.downloadUpdate() @@ -524,32 +541,32 @@ class DefaultChatListComponent( override fun handleBack(): Boolean { return when { - state.value.webViewUrl != null -> { + uiState.value.webViewUrl != null -> { onDismissWebView() true } - state.value.webAppUrl != null -> { + uiState.value.webAppUrl != null -> { onDismissWebApp() true } - state.value.instantViewUrl != null -> { + uiState.value.instantViewUrl != null -> { onDismissInstantView() true } - state.value.isSearchActive -> { + searchState.value.isSearchActive -> { onSearchToggle() true } - state.value.selectedChatIds.isNotEmpty() -> { + selectionState.value.selectedChatIds.isNotEmpty() -> { clearSelection() true } - state.value.selectedFolderId == -2 -> { + foldersState.value.selectedFolderId == -2 -> { onFolderClicked(-1) true } - state.value.isForwarding -> { + uiState.value.isForwarding -> { onSelect(0L, null) true } @@ -558,7 +575,7 @@ class DefaultChatListComponent( } override fun updateScrollPosition(folderId: Int, index: Int, offset: Int) { - _state.update { + _foldersState.update { val newPositions = it.scrollPositions.toMutableMap() newPositions[folderId] = index to offset it.copy(scrollPositions = newPositions) @@ -570,13 +587,12 @@ class DefaultChatListComponent( } private fun toggleSelection(id: Long) { - _state.update { state -> - val newSelection = if (state.selectedChatIds.contains(id)) { - state.selectedChatIds - id - } else { - state.selectedChatIds + id - } - state.copy(selectedChatIds = newSelection) + val currentSelection = _selectionState.value.selectedChatIds + val newSelection = if (currentSelection.contains(id)) { + currentSelection - id + } else { + currentSelection + id } + _selectionState.value = _selectionState.value.copy(selectedChatIds = newSelection) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt index 3e9c0d24..8d5d5383 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatListItem.kt @@ -343,9 +343,9 @@ private fun ChatListItemContent( } else { emptyMap() } + val spoilerLabel = stringResource(R.string.message_spoiler) val annotatedDraft = if (draftHasSpoiler) { buildAnnotatedString { - val spoilerLabel = stringResource(R.string.message_spoiler) append(spoilerLabel) addStyle( SpanStyle( @@ -382,11 +382,11 @@ private fun ChatListItemContent( entities = chat.lastMessageEntities, fontSize = fontSize ) + val spoilerLabel = stringResource(R.string.message_spoiler) val annotatedText = if (chat.lastMessageText.isNotEmpty()) { val hasSpoiler = chat.lastMessageEntities.any { it.type is MessageEntityType.Spoiler } if (hasSpoiler) { buildAnnotatedString { - val spoilerLabel = stringResource(R.string.message_spoiler) append(spoilerLabel) addStyle( SpanStyle( @@ -505,4 +505,4 @@ private fun ChatListItemStatus(chat: ChatModel) { ) } } -} \ No newline at end of file +}