android: add global search and message thread API parity
Some checks failed
Android CI / android (push) Failing after 4m59s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 22:48:36 +03:00
parent 7824ab1a55
commit b294297dbd
15 changed files with 297 additions and 40 deletions

View File

@@ -656,3 +656,12 @@
- Wired these actions into the existing Chat Management panel:
- edit selected chat title,
- edit selected chat profile fields (title/description).
### Step 102 - Global search + message thread parity
- Added Android data-layer integration for unified backend global search:
- `GET /api/v1/search`
- new `SearchRepository` + `SearchApiService` returning `users/chats/messages`.
- Switched chats fullscreen search flow to use unified backend search instead of composed per-domain calls.
- Extended message data layer with:
- `GET /api/v1/messages/{message_id}/thread`
- `MessageRepository.getMessageThread(...)` for thread/replies usage in upcoming UI steps.

View File

@@ -30,6 +30,12 @@ interface MessageApiService {
@Query("chat_id") chatId: Long? = null,
): List<MessageReadDto>
@GET("/api/v1/messages/{message_id}/thread")
suspend fun getMessageThread(
@Path("message_id") messageId: Long,
@Query("limit") limit: Int = 100,
): List<MessageReadDto>
@POST("/api/v1/messages")
suspend fun sendMessage(
@Body request: MessageCreateRequestDto,

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
import ru.daemonlord.messenger.data.message.api.MessageApiService
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
@@ -76,34 +77,22 @@ class NetworkMessageRepository @Inject constructor(
if (normalized.isBlank()) return@withContext AppResult.Success(emptyList())
try {
val remote = messageApiService.searchMessages(query = normalized, chatId = chatId)
val mapped = remote.map { dto ->
val entity = dto.toEntity()
MessageItem(
id = entity.id,
chatId = entity.chatId,
senderId = entity.senderId,
senderDisplayName = entity.senderDisplayName,
type = entity.type,
text = entity.text,
createdAt = entity.createdAt,
updatedAt = entity.updatedAt,
isOutgoing = currentUserId != null && currentUserId == entity.senderId,
status = entity.status,
replyToMessageId = entity.replyToMessageId,
replyPreviewText = entity.replyPreviewText,
replyPreviewSenderName = entity.replyPreviewSenderName,
forwardedFromMessageId = entity.forwardedFromMessageId,
forwardedFromDisplayName = entity.forwardedFromDisplayName,
attachmentWaveform = null,
attachments = emptyList(),
)
}
val mapped = remote.map { dto -> dto.toDomain(currentUserId = currentUserId) }
AppResult.Success(mapped)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun getMessageThread(messageId: Long, limit: Int): AppResult<List<MessageItem>> = withContext(ioDispatcher) {
try {
val remote = messageApiService.getMessageThread(messageId = messageId, limit = limit)
AppResult.Success(remote.map { dto -> dto.toDomain(currentUserId = currentUserId) })
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
override suspend fun syncRecentMessages(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
flushPendingActions(chatId = chatId)
try {
@@ -602,6 +591,28 @@ class NetworkMessageRepository @Inject constructor(
DELETE,
}
private fun MessageReadDto.toDomain(currentUserId: Long?): MessageItem {
return MessageItem(
id = id,
chatId = chatId,
senderId = senderId,
senderDisplayName = senderDisplayName,
type = type,
text = text,
createdAt = createdAt,
updatedAt = updatedAt,
isOutgoing = currentUserId != null && currentUserId == senderId,
status = deliveryStatus,
replyToMessageId = replyToMessageId,
replyPreviewText = replyPreviewText,
replyPreviewSenderName = replyPreviewSenderName,
forwardedFromMessageId = forwardedFromMessageId,
forwardedFromDisplayName = forwardedFromDisplayName,
attachmentWaveform = attachmentWaveform,
attachments = emptyList(),
)
}
private fun String.extractUserIdFromJwt(): Long? {
val payloadPart = split('.').getOrNull(1) ?: return null
val normalized = payloadPart

View File

@@ -0,0 +1,15 @@
package ru.daemonlord.messenger.data.search.api
import retrofit2.http.GET
import retrofit2.http.Query
import ru.daemonlord.messenger.data.search.dto.GlobalSearchResponseDto
interface SearchApiService {
@GET("/api/v1/search")
suspend fun globalSearch(
@Query("query") query: String,
@Query("users_limit") usersLimit: Int = 10,
@Query("chats_limit") chatsLimit: Int = 10,
@Query("messages_limit") messagesLimit: Int = 10,
): GlobalSearchResponseDto
}

View File

@@ -0,0 +1,13 @@
package ru.daemonlord.messenger.data.search.dto
import kotlinx.serialization.Serializable
import ru.daemonlord.messenger.data.chat.dto.DiscoverChatDto
import ru.daemonlord.messenger.data.message.dto.MessageReadDto
import ru.daemonlord.messenger.data.user.dto.UserSearchDto
@Serializable
data class GlobalSearchResponseDto(
val users: List<UserSearchDto> = emptyList(),
val chats: List<DiscoverChatDto> = emptyList(),
val messages: List<MessageReadDto> = emptyList(),
)

View File

@@ -0,0 +1,93 @@
package ru.daemonlord.messenger.data.search.repository
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.data.search.api.SearchApiService
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.model.MessageItem
import ru.daemonlord.messenger.domain.search.model.GlobalSearchResult
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class NetworkSearchRepository @Inject constructor(
private val searchApiService: SearchApiService,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : SearchRepository {
override suspend fun globalSearch(
query: String,
usersLimit: Int,
chatsLimit: Int,
messagesLimit: Int,
): AppResult<GlobalSearchResult> = withContext(ioDispatcher) {
val normalized = query.trim()
if (normalized.isBlank()) return@withContext AppResult.Success(
GlobalSearchResult(
users = emptyList(),
chats = emptyList(),
messages = emptyList(),
)
)
try {
val response = searchApiService.globalSearch(
query = normalized,
usersLimit = usersLimit,
chatsLimit = chatsLimit,
messagesLimit = messagesLimit,
)
AppResult.Success(
GlobalSearchResult(
users = response.users.map { dto ->
UserSearchItem(
id = dto.id,
name = dto.name?.trim().takeUnless { it.isNullOrBlank() }
?: dto.username?.trim().takeUnless { it.isNullOrBlank() }
?: "User #${dto.id}",
username = dto.username,
avatarUrl = dto.avatarUrl,
)
},
chats = response.chats.map { dto ->
DiscoverChatItem(
id = dto.id,
type = dto.type,
displayTitle = dto.displayTitle,
handle = dto.handle,
avatarUrl = dto.avatarUrl,
isMember = dto.isMember,
)
},
messages = response.messages.map { dto ->
MessageItem(
id = dto.id,
chatId = dto.chatId,
senderId = dto.senderId,
senderDisplayName = dto.senderDisplayName,
type = dto.type,
text = dto.text,
createdAt = dto.createdAt,
updatedAt = dto.updatedAt,
isOutgoing = false,
status = dto.deliveryStatus,
replyToMessageId = dto.replyToMessageId,
replyPreviewText = dto.replyPreviewText,
replyPreviewSenderName = dto.replyPreviewSenderName,
forwardedFromMessageId = dto.forwardedFromMessageId,
forwardedFromDisplayName = dto.forwardedFromDisplayName,
attachmentWaveform = dto.attachmentWaveform,
attachments = emptyList(),
)
},
)
)
} catch (error: Throwable) {
AppResult.Error(error.toAppError())
}
}
}

View File

@@ -19,6 +19,7 @@ import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.chat.api.ChatApiService
import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.message.api.MessageApiService
import ru.daemonlord.messenger.data.search.api.SearchApiService
import ru.daemonlord.messenger.data.user.api.UserApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import java.util.concurrent.TimeUnit
@@ -147,4 +148,10 @@ object NetworkModule {
fun provideUserApiService(retrofit: Retrofit): UserApiService {
return retrofit.create(UserApiService::class.java)
}
@Provides
@Singleton
fun provideSearchApiService(retrofit: Retrofit): SearchApiService {
return retrofit.create(SearchApiService::class.java)
}
}

View File

@@ -11,6 +11,7 @@ import ru.daemonlord.messenger.data.chat.repository.DataStoreChatSearchRepositor
import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
import ru.daemonlord.messenger.data.search.repository.NetworkSearchRepository
import ru.daemonlord.messenger.data.user.repository.NetworkAccountRepository
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
@@ -20,6 +21,7 @@ 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
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
import javax.inject.Singleton
@Module
@@ -73,4 +75,10 @@ abstract class RepositoryModule {
abstract fun bindAccountRepository(
repository: NetworkAccountRepository,
): AccountRepository
@Binds
@Singleton
abstract fun bindSearchRepository(
repository: NetworkSearchRepository,
): SearchRepository
}

View File

@@ -8,6 +8,7 @@ import ru.daemonlord.messenger.domain.message.model.MessageReaction
interface MessageRepository {
fun observeMessages(chatId: Long, limit: Int = 50): Flow<List<MessageItem>>
suspend fun searchMessages(query: String, chatId: Long? = null): AppResult<List<MessageItem>>
suspend fun getMessageThread(messageId: Long, limit: Int = 100): AppResult<List<MessageItem>>
suspend fun syncRecentMessages(chatId: Long): AppResult<Unit>
suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit>
suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit>

View File

@@ -0,0 +1,11 @@
package ru.daemonlord.messenger.domain.search.model
import ru.daemonlord.messenger.domain.account.model.UserSearchItem
import ru.daemonlord.messenger.domain.chat.model.DiscoverChatItem
import ru.daemonlord.messenger.domain.message.model.MessageItem
data class GlobalSearchResult(
val users: List<UserSearchItem>,
val chats: List<DiscoverChatItem>,
val messages: List<MessageItem>,
)

View File

@@ -0,0 +1,13 @@
package ru.daemonlord.messenger.domain.search.repository
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.search.model.GlobalSearchResult
interface SearchRepository {
suspend fun globalSearch(
query: String,
usersLimit: Int = 10,
chatsLimit: Int = 10,
messagesLimit: Int = 10,
): AppResult<GlobalSearchResult>
}

View File

@@ -946,7 +946,27 @@ private fun ChatSearchFullscreen(
},
)
}
} else {
}
if (state.globalChats.isNotEmpty()) {
val chats = if (showMoreGlobalUsers) state.globalChats else state.globalChats.take(5)
items(chats, key = { "global_chat_${it.id}" }) { chat ->
SearchGlobalChatRow(
title = chat.displayTitle,
subtitle = buildString {
append(chat.type.lowercase())
chat.handle?.takeIf { it.isNotBlank() }?.let {
append(" · @")
append(it)
}
},
onClick = {
onSearchResultOpened(chat.id)
onOpenChat(chat.id)
},
)
}
}
if (state.globalUsers.isEmpty() && state.globalChats.isEmpty()) {
item(key = "global_empty") {
Text(
text = "Нет результатов",
@@ -976,7 +996,7 @@ private fun ChatSearchFullscreen(
chatId = message.chatId,
)
}
if (localQueryResults.isEmpty() && state.globalUsers.isEmpty() && state.globalMessages.isEmpty()) {
if (localQueryResults.isEmpty() && state.globalUsers.isEmpty() && state.globalChats.isEmpty() && state.globalMessages.isEmpty()) {
item(key = "all_empty") {
Text(
text = "Ничего не найдено",
@@ -1058,6 +1078,50 @@ private fun SearchChatRow(
}
}
@Composable
private fun SearchGlobalChatRow(
title: String,
subtitle: String,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center,
) {
Text(
text = title.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.titleMedium,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun SearchUserRow(
title: String,

View File

@@ -23,6 +23,7 @@ data class ChatListUiState(
val isJoiningInvite: Boolean = false,
val pendingOpenChatId: Long? = null,
val discoverChats: List<DiscoverChatItem> = emptyList(),
val globalChats: List<DiscoverChatItem> = emptyList(),
val selectedManageChatId: Long? = null,
val members: List<ChatMemberItem> = emptyList(),
val bans: List<ChatBanItem> = emptyList(),

View File

@@ -16,16 +16,15 @@ 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
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
import javax.inject.Inject
@HiltViewModel
@@ -38,8 +37,7 @@ class ChatListViewModel @Inject constructor(
private val realtimeManager: RealtimeManager,
private val chatRepository: ChatRepository,
private val chatSearchRepository: ChatSearchRepository,
private val accountRepository: AccountRepository,
private val messageRepository: MessageRepository,
private val searchRepository: SearchRepository,
) : ViewModel() {
private val selectedTab = MutableStateFlow(ChatTab.ALL)
@@ -122,17 +120,26 @@ class ChatListViewModel @Inject constructor(
_uiState.update { it.copy(globalSearchQuery = value) }
val normalized = value.trim()
if (normalized.length < 2) {
_uiState.update { it.copy(globalUsers = emptyList(), globalMessages = emptyList()) }
_uiState.update { it.copy(globalUsers = emptyList(), globalChats = emptyList(), globalMessages = emptyList()) }
return
}
viewModelScope.launch {
val usersResult = accountRepository.searchUsers(query = normalized, limit = 10)
val messagesResult = messageRepository.searchMessages(query = normalized, chatId = null)
_uiState.update {
it.copy(
globalUsers = (usersResult as? AppResult.Success)?.data ?: emptyList(),
globalMessages = (messagesResult as? AppResult.Success)?.data?.take(20) ?: emptyList(),
)
when (val result = searchRepository.globalSearch(query = normalized, usersLimit = 10, chatsLimit = 10, messagesLimit = 20)) {
is AppResult.Success -> _uiState.update {
it.copy(
globalUsers = result.data.users,
globalChats = result.data.chats,
globalMessages = result.data.messages,
)
}
is AppResult.Error -> _uiState.update {
it.copy(
globalUsers = emptyList(),
globalChats = emptyList(),
globalMessages = emptyList(),
errorMessage = result.reason.toUiMessage(),
)
}
}
}
}