android: persist chats search history and recent in datastore
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user