android: persist chats search history and recent in datastore
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 22:07:59 +03:00
parent 18844ec06a
commit c4d1e7f1fb
7 changed files with 185 additions and 7 deletions

View File

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

View File

@@ -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<Preferences>,
) : ChatSearchRepository {
override fun observeHistoryChatIds(): Flow<List<Long>> {
return dataStore.data.map { prefs -> decodeIds(prefs[HISTORY_IDS_KEY]) }
}
override fun observeRecentChatIds(): Flow<List<Long>> {
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<Long>): List<Long> {
return buildList {
add(chatId)
addAll(source.filterNot { it == chatId })
}.take(MAX_SIZE)
}
private fun decodeIds(raw: String?): List<Long> {
if (raw.isNullOrBlank()) return emptyList()
return raw.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.distinct()
.take(MAX_SIZE)
}
private fun encodeIds(ids: List<Long>): 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")
}
}

View File

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

View File

@@ -0,0 +1,12 @@
package ru.daemonlord.messenger.domain.chat.repository
import kotlinx.coroutines.flow.Flow
interface ChatSearchRepository {
fun observeHistoryChatIds(): Flow<List<Long>>
fun observeRecentChatIds(): Flow<List<Long>>
suspend fun addHistoryChat(chatId: Long)
suspend fun addRecentChat(chatId: Long)
suspend fun clearHistory()
}

View File

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

View File

@@ -16,6 +16,8 @@ data class ChatListUiState(
val isConnecting: Boolean = false,
val errorMessage: String? = null,
val chats: List<ChatItem> = emptyList(),
val searchHistoryChats: List<ChatItem> = emptyList(),
val searchRecentChats: List<ChatItem> = emptyList(),
val archivedChatsCount: Int = 0,
val archivedUnreadCount: Int = 0,
val isJoiningInvite: Boolean = false,

View File

@@ -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<List<Long>>(emptyList())
private val searchRecentIds = MutableStateFlow<List<Long>>(emptyList())
private var lastHandledInviteToken: String? = null
private val _uiState = MutableStateFlow(ChatListUiState())
val uiState: StateFlow<ChatListUiState> = _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,