diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 97dc2b8..a2871b2 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt index a5bb8a6..9e7b4eb 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/api/MessageApiService.kt @@ -30,6 +30,12 @@ interface MessageApiService { @Query("chat_id") chatId: Long? = null, ): List + @GET("/api/v1/messages/{message_id}/thread") + suspend fun getMessageThread( + @Path("message_id") messageId: Long, + @Query("limit") limit: Int = 100, + ): List + @POST("/api/v1/messages") suspend fun sendMessage( @Body request: MessageCreateRequestDto, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt index 57d978d..42a3e3d 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -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> = 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 = 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 diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/search/api/SearchApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/search/api/SearchApiService.kt new file mode 100644 index 0000000..a34d7bf --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/search/api/SearchApiService.kt @@ -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 +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/search/dto/SearchDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/search/dto/SearchDtos.kt new file mode 100644 index 0000000..2c9cd8a --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/search/dto/SearchDtos.kt @@ -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 = emptyList(), + val chats: List = emptyList(), + val messages: List = emptyList(), +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/search/repository/NetworkSearchRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/search/repository/NetworkSearchRepository.kt new file mode 100644 index 0000000..de5b95c --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/search/repository/NetworkSearchRepository.kt @@ -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 = 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()) + } + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt index b7a7a67..b49ed87 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt @@ -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) + } } 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 0c05d76..ebe5681 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 @@ -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 } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt index d2b260e..dc48c75 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt @@ -8,6 +8,7 @@ import ru.daemonlord.messenger.domain.message.model.MessageReaction interface MessageRepository { fun observeMessages(chatId: Long, limit: Int = 50): Flow> suspend fun searchMessages(query: String, chatId: Long? = null): AppResult> + suspend fun getMessageThread(messageId: Long, limit: Int = 100): AppResult> suspend fun syncRecentMessages(chatId: Long): AppResult suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/search/model/GlobalSearchResult.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/search/model/GlobalSearchResult.kt new file mode 100644 index 0000000..13e35aa --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/search/model/GlobalSearchResult.kt @@ -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, + val chats: List, + val messages: List, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/search/repository/SearchRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/search/repository/SearchRepository.kt new file mode 100644 index 0000000..30cfa9f --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/search/repository/SearchRepository.kt @@ -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 +} 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 592b46d..91adc2e 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 @@ -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, 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 87ef763..1c7d2a4 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 @@ -23,6 +23,7 @@ data class ChatListUiState( val isJoiningInvite: Boolean = false, val pendingOpenChatId: Long? = null, val discoverChats: List = emptyList(), + val globalChats: List = emptyList(), val selectedManageChatId: Long? = null, val members: List = emptyList(), val bans: List = emptyList(), 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 32a7d9a..b1132c1 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 @@ -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(), + ) + } } } } diff --git a/docs/backend-web-android-parity.md b/docs/backend-web-android-parity.md index babefa4..cb0c79f 100644 --- a/docs/backend-web-android-parity.md +++ b/docs/backend-web-android-parity.md @@ -17,8 +17,7 @@ Backend покрывает web-функционал почти полность ## 2) Web endpoints not yet fully used on Android -- `GET /api/v1/messages/{message_id}/thread` -- `GET /api/v1/search` (single global endpoint; Android uses composed search calls) +- `GET /api/v1/messages/{message_id}/thread` (data layer wired, UI thread screen/jump usage pending) - Contacts endpoints: - `GET /api/v1/users/contacts` - `POST /api/v1/users/{user_id}/contacts` @@ -36,6 +35,5 @@ Backend покрывает web-функционал почти полность Завершить следующий parity-блок: -- `GET /api/v1/messages/{message_id}/thread` -- единый `GET /api/v1/search` для полнофункционального Telegram-like поиска +- `GET /api/v1/messages/{message_id}/thread` (UI usage) - contacts API (`/users/contacts*`) + экран управления контактами