diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index d9c7de5..9d76471 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -577,3 +577,14 @@ - empty query -> `Recent` list block (history-style chat rows), - non-empty query -> local matches + `Global search` and `Messages` sections. - Kept search action in chats top bar; while search mode is active, app bar switches to back-navigation + empty title (content drives the page). + +### Step 91 - Search history/recent persistence + clear action +- Added `ChatSearchRepository` abstraction and `DataStoreChatSearchRepository` implementation. +- Persisted chats search metadata in `DataStore`: + - recent opened chats list, + - search history list (bounded). +- Wired chats fullscreen search to persisted data: + - green recent avatars strip now reads saved recent chats, + - red `Recent` list now reads saved history with fallback. +- Connected `Очистить` action to real history cleanup in `DataStore`. +- On opening a chat from search results/messages/history, the chat is now stored in recent/history. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/DataStoreChatSearchRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/DataStoreChatSearchRepository.kt new file mode 100644 index 0000000..1133775 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/chat/repository/DataStoreChatSearchRepository.kt @@ -0,0 +1,67 @@ +package ru.daemonlord.messenger.data.chat.repository + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import ru.daemonlord.messenger.domain.chat.repository.ChatSearchRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DataStoreChatSearchRepository @Inject constructor( + private val dataStore: DataStore, +) : ChatSearchRepository { + + override fun observeHistoryChatIds(): Flow> { + return dataStore.data.map { prefs -> decodeIds(prefs[HISTORY_IDS_KEY]) } + } + + override fun observeRecentChatIds(): Flow> { + return dataStore.data.map { prefs -> decodeIds(prefs[RECENT_IDS_KEY]) } + } + + override suspend fun addHistoryChat(chatId: Long) { + dataStore.edit { prefs -> + prefs[HISTORY_IDS_KEY] = encodeIds(prepend(chatId, decodeIds(prefs[HISTORY_IDS_KEY]))) + } + } + + override suspend fun addRecentChat(chatId: Long) { + dataStore.edit { prefs -> + prefs[RECENT_IDS_KEY] = encodeIds(prepend(chatId, decodeIds(prefs[RECENT_IDS_KEY]))) + } + } + + override suspend fun clearHistory() { + dataStore.edit { prefs -> + prefs.remove(HISTORY_IDS_KEY) + } + } + + private fun prepend(chatId: Long, source: List): List { + return buildList { + add(chatId) + addAll(source.filterNot { it == chatId }) + }.take(MAX_SIZE) + } + + private fun decodeIds(raw: String?): List { + if (raw.isNullOrBlank()) return emptyList() + return raw.split(',') + .mapNotNull { it.trim().toLongOrNull() } + .distinct() + .take(MAX_SIZE) + } + + private fun encodeIds(ids: List): String = ids.joinToString(",") + + private companion object { + const val MAX_SIZE = 30 + val HISTORY_IDS_KEY = stringPreferencesKey("chat_search_history_ids") + val RECENT_IDS_KEY = stringPreferencesKey("chat_search_recent_ids") + } +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt index da03c17..0c05d76 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt @@ -7,6 +7,7 @@ import dagger.hilt.components.SingletonComponent import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository import ru.daemonlord.messenger.data.auth.repository.DefaultSessionCleanupRepository import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository +import ru.daemonlord.messenger.data.chat.repository.DataStoreChatSearchRepository import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository @@ -15,6 +16,7 @@ import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.auth.repository.AuthRepository import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository +import ru.daemonlord.messenger.domain.chat.repository.ChatSearchRepository import ru.daemonlord.messenger.domain.media.repository.MediaRepository import ru.daemonlord.messenger.domain.message.repository.MessageRepository import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository @@ -42,6 +44,12 @@ abstract class RepositoryModule { repository: NetworkChatRepository, ): ChatRepository + @Binds + @Singleton + abstract fun bindChatSearchRepository( + repository: DataStoreChatSearchRepository, + ): ChatSearchRepository + @Binds @Singleton abstract fun bindMessageRepository( diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatSearchRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatSearchRepository.kt new file mode 100644 index 0000000..04c089d --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/chat/repository/ChatSearchRepository.kt @@ -0,0 +1,12 @@ +package ru.daemonlord.messenger.domain.chat.repository + +import kotlinx.coroutines.flow.Flow + +interface ChatSearchRepository { + fun observeHistoryChatIds(): Flow> + fun observeRecentChatIds(): Flow> + suspend fun addHistoryChat(chatId: Long) + suspend fun addRecentChat(chatId: Long) + suspend fun clearHistory() +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt index 042ebd2..5202329 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt @@ -110,6 +110,8 @@ fun ChatListRoute( onFilterSelected = viewModel::onFilterSelected, onSearchChanged = viewModel::onSearchChanged, onGlobalSearchChanged = viewModel::onGlobalSearchChanged, + onSearchResultOpened = viewModel::onSearchResultOpened, + onClearSearchHistory = viewModel::clearSearchHistory, onRefresh = viewModel::onPullToRefresh, onOpenChat = onOpenChat, isMainBarVisible = isMainBarVisible, @@ -137,6 +139,8 @@ fun ChatListScreen( onFilterSelected: (ChatListFilter) -> Unit, onSearchChanged: (String) -> Unit, onGlobalSearchChanged: (String) -> Unit, + onSearchResultOpened: (Long) -> Unit, + onClearSearchHistory: () -> Unit, onRefresh: () -> Unit, onOpenChat: (Long) -> Unit, isMainBarVisible: Boolean, @@ -496,6 +500,8 @@ fun ChatListScreen( onGlobalSearchChanged(it) }, onSectionChanged = { searchSection = it }, + onSearchResultOpened = onSearchResultOpened, + onClearHistory = onClearSearchHistory, onOpenChat = onOpenChat, ) } @@ -692,6 +698,8 @@ private fun ChatSearchFullscreen( searchQuery: String, onSearchChanged: (String) -> Unit, onSectionChanged: (SearchSection) -> Unit, + onSearchResultOpened: (Long) -> Unit, + onClearHistory: () -> Unit, onOpenChat: (Long) -> Unit, ) { val trimmedQuery = searchQuery.trim() @@ -715,8 +723,14 @@ private fun ChatSearchFullscreen( } } } - val recentCircleChats = remember(sectionChats) { sectionChats.take(8) } - val recentHistoryChats = remember(sectionChats) { sectionChats.take(14) } + val recentCircleChats = remember(state.searchRecentChats, sectionChats) { + val byId = sectionChats.associateBy { it.id } + state.searchRecentChats.mapNotNull { byId[it.id] }.take(8).ifEmpty { sectionChats.take(8) } + } + val recentHistoryChats = remember(state.searchHistoryChats, sectionChats) { + val byId = sectionChats.associateBy { it.id } + state.searchHistoryChats.mapNotNull { byId[it.id] }.take(20).ifEmpty { sectionChats.take(14) } + } Column( modifier = Modifier @@ -778,7 +792,10 @@ private fun ChatSearchFullscreen( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .widthIn(max = 74.dp) - .clickable { onOpenChat(chat.id) }, + .clickable { + onSearchResultOpened(chat.id) + onOpenChat(chat.id) + }, ) { ChatAvatar(chat = chat, size = 56.dp) Text( @@ -808,18 +825,31 @@ private fun ChatSearchFullscreen( text = "Очистить", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, + modifier = Modifier.clickable(onClick = onClearHistory), ) } LazyColumn(modifier = Modifier.fillMaxSize()) { items(recentHistoryChats, key = { "recent_${it.id}" }) { chat -> - SearchChatRow(chat = chat, onClick = { onOpenChat(chat.id) }) + SearchChatRow( + chat = chat, + onClick = { + onSearchResultOpened(chat.id) + onOpenChat(chat.id) + }, + ) } } } else { LazyColumn(modifier = Modifier.fillMaxSize()) { if (localQueryResults.isNotEmpty()) { items(localQueryResults.take(6), key = { "local_${it.id}" }) { chat -> - SearchChatRow(chat = chat, onClick = { onOpenChat(chat.id) }) + SearchChatRow( + chat = chat, + onClick = { + onSearchResultOpened(chat.id) + onOpenChat(chat.id) + }, + ) } } item(key = "global_header") { @@ -859,7 +889,10 @@ private fun ChatSearchFullscreen( state = state, messageText = message.text?.take(70).orEmpty().ifBlank { "[${message.type}]" }, time = formatChatTime(message.createdAt), - onClick = { onOpenChat(message.chatId) }, + onClick = { + onSearchResultOpened(message.chatId) + onOpenChat(message.chatId) + }, chatId = message.chatId, ) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt index f75987f..87ef763 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListUiState.kt @@ -16,6 +16,8 @@ data class ChatListUiState( val isConnecting: Boolean = false, val errorMessage: String? = null, val chats: List = emptyList(), + val searchHistoryChats: List = emptyList(), + val searchRecentChats: List = emptyList(), val archivedChatsCount: Int = 0, val archivedUnreadCount: Int = 0, val isJoiningInvite: Boolean = false, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt index 8307945..fd275c1 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import ru.daemonlord.messenger.domain.chat.model.ChatItem import ru.daemonlord.messenger.domain.chat.repository.ChatRepository +import ru.daemonlord.messenger.domain.chat.repository.ChatSearchRepository import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.chat.usecase.JoinByInviteUseCase import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase @@ -36,6 +37,7 @@ class ChatListViewModel @Inject constructor( private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase, private val realtimeManager: RealtimeManager, private val chatRepository: ChatRepository, + private val chatSearchRepository: ChatSearchRepository, private val accountRepository: AccountRepository, private val messageRepository: MessageRepository, ) : ViewModel() { @@ -43,12 +45,15 @@ class ChatListViewModel @Inject constructor( private val selectedTab = MutableStateFlow(ChatTab.ALL) private val selectedFilter = MutableStateFlow(ChatListFilter.ALL) private val searchQuery = MutableStateFlow("") + private val searchHistoryIds = MutableStateFlow>(emptyList()) + private val searchRecentIds = MutableStateFlow>(emptyList()) private var lastHandledInviteToken: String? = null private val _uiState = MutableStateFlow(ChatListUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { handleRealtimeEventsUseCase.start() + observeSearchStore() observeConnectionState() observeChatStream() } @@ -132,6 +137,19 @@ class ChatListViewModel @Inject constructor( } } + fun onSearchResultOpened(chatId: Long) { + viewModelScope.launch { + chatSearchRepository.addHistoryChat(chatId) + chatSearchRepository.addRecentChat(chatId) + } + } + + fun clearSearchHistory() { + viewModelScope.launch { + chatSearchRepository.clearHistory() + } + } + fun onManagementChatSelected(chatId: Long?) { _uiState.update { it.copy(selectedManageChatId = chatId) } if (chatId != null) { @@ -299,16 +317,30 @@ class ChatListViewModel @Inject constructor( .combine(selectedFilter) { (chats, query), filter -> chats.filterByQueryAndType(query = query, filter = filter) } + .combine(searchHistoryIds) { filtered, historyIds -> + filtered to historyIds + } + .combine(searchRecentIds) { (filtered, historyIds), recentIds -> + Triple(filtered, historyIds, recentIds) + } .combine(archiveStatsFlow) { filtered, stats -> filtered to stats } - .collectLatest { (filtered, stats) -> + .collectLatest { (filteredWithSearch, stats) -> + val filtered = filteredWithSearch.first + val historyIds = filteredWithSearch.second + val recentIds = filteredWithSearch.third + val byId = filtered.associateBy { it.id } + val historyChats = historyIds.mapNotNull(byId::get) + val recentChats = recentIds.mapNotNull(byId::get) _uiState.update { it.copy( isLoading = false, isRefreshing = false, errorMessage = null, chats = filtered, + searchHistoryChats = historyChats, + searchRecentChats = recentChats, archivedChatsCount = stats.first, archivedUnreadCount = stats.second, managementMessage = null, @@ -331,6 +363,19 @@ class ChatListViewModel @Inject constructor( } } + private fun observeSearchStore() { + viewModelScope.launch { + chatSearchRepository.observeHistoryChatIds().collectLatest { ids -> + searchHistoryIds.value = ids + } + } + viewModelScope.launch { + chatSearchRepository.observeRecentChatIds().collectLatest { ids -> + searchRecentIds.value = ids + } + } + } + private fun createChatInternal( type: String, title: String,