android: add global search and message thread API parity
This commit is contained in:
@@ -656,3 +656,12 @@
|
|||||||
- Wired these actions into the existing Chat Management panel:
|
- Wired these actions into the existing Chat Management panel:
|
||||||
- edit selected chat title,
|
- edit selected chat title,
|
||||||
- edit selected chat profile fields (title/description).
|
- 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.
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ interface MessageApiService {
|
|||||||
@Query("chat_id") chatId: Long? = null,
|
@Query("chat_id") chatId: Long? = null,
|
||||||
): List<MessageReadDto>
|
): 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")
|
@POST("/api/v1/messages")
|
||||||
suspend fun sendMessage(
|
suspend fun sendMessage(
|
||||||
@Body request: MessageCreateRequestDto,
|
@Body request: MessageCreateRequestDto,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
import ru.daemonlord.messenger.data.chat.local.dao.ChatDao
|
||||||
import ru.daemonlord.messenger.data.message.api.MessageApiService
|
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.core.token.TokenRepository
|
||||||
import ru.daemonlord.messenger.data.common.toAppError
|
import ru.daemonlord.messenger.data.common.toAppError
|
||||||
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
|
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())
|
if (normalized.isBlank()) return@withContext AppResult.Success(emptyList())
|
||||||
try {
|
try {
|
||||||
val remote = messageApiService.searchMessages(query = normalized, chatId = chatId)
|
val remote = messageApiService.searchMessages(query = normalized, chatId = chatId)
|
||||||
val mapped = remote.map { dto ->
|
val mapped = remote.map { dto -> dto.toDomain(currentUserId = currentUserId) }
|
||||||
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(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
AppResult.Success(mapped)
|
AppResult.Success(mapped)
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
AppResult.Error(error.toAppError())
|
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) {
|
override suspend fun syncRecentMessages(chatId: Long): AppResult<Unit> = withContext(ioDispatcher) {
|
||||||
flushPendingActions(chatId = chatId)
|
flushPendingActions(chatId = chatId)
|
||||||
try {
|
try {
|
||||||
@@ -602,6 +591,28 @@ class NetworkMessageRepository @Inject constructor(
|
|||||||
DELETE,
|
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? {
|
private fun String.extractUserIdFromJwt(): Long? {
|
||||||
val payloadPart = split('.').getOrNull(1) ?: return null
|
val payloadPart = split('.').getOrNull(1) ?: return null
|
||||||
val normalized = payloadPart
|
val normalized = payloadPart
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.chat.api.ChatApiService
|
||||||
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
import ru.daemonlord.messenger.data.media.api.MediaApiService
|
||||||
import ru.daemonlord.messenger.data.message.api.MessageApiService
|
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 ru.daemonlord.messenger.data.user.api.UserApiService
|
||||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -147,4 +148,10 @@ object NetworkModule {
|
|||||||
fun provideUserApiService(retrofit: Retrofit): UserApiService {
|
fun provideUserApiService(retrofit: Retrofit): UserApiService {
|
||||||
return retrofit.create(UserApiService::class.java)
|
return retrofit.create(UserApiService::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSearchApiService(retrofit: Retrofit): SearchApiService {
|
||||||
|
return retrofit.create(SearchApiService::class.java)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.media.repository.NetworkMediaRepository
|
||||||
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
|
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
|
||||||
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
|
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.data.user.repository.NetworkAccountRepository
|
||||||
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
|
||||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
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.media.repository.MediaRepository
|
||||||
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
||||||
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||||
|
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -73,4 +75,10 @@ abstract class RepositoryModule {
|
|||||||
abstract fun bindAccountRepository(
|
abstract fun bindAccountRepository(
|
||||||
repository: NetworkAccountRepository,
|
repository: NetworkAccountRepository,
|
||||||
): AccountRepository
|
): AccountRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSearchRepository(
|
||||||
|
repository: NetworkSearchRepository,
|
||||||
|
): SearchRepository
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ru.daemonlord.messenger.domain.message.model.MessageReaction
|
|||||||
interface MessageRepository {
|
interface MessageRepository {
|
||||||
fun observeMessages(chatId: Long, limit: Int = 50): Flow<List<MessageItem>>
|
fun observeMessages(chatId: Long, limit: Int = 50): Flow<List<MessageItem>>
|
||||||
suspend fun searchMessages(query: String, chatId: Long? = null): AppResult<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 syncRecentMessages(chatId: Long): AppResult<Unit>
|
||||||
suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit>
|
suspend fun loadMoreMessages(chatId: Long, beforeMessageId: Long): AppResult<Unit>
|
||||||
suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit>
|
suspend fun sendTextMessage(chatId: Long, text: String, replyToMessageId: Long? = null): AppResult<Unit>
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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") {
|
item(key = "global_empty") {
|
||||||
Text(
|
Text(
|
||||||
text = "Нет результатов",
|
text = "Нет результатов",
|
||||||
@@ -976,7 +996,7 @@ private fun ChatSearchFullscreen(
|
|||||||
chatId = message.chatId,
|
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") {
|
item(key = "all_empty") {
|
||||||
Text(
|
Text(
|
||||||
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
|
@Composable
|
||||||
private fun SearchUserRow(
|
private fun SearchUserRow(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ data class ChatListUiState(
|
|||||||
val isJoiningInvite: Boolean = false,
|
val isJoiningInvite: Boolean = false,
|
||||||
val pendingOpenChatId: Long? = null,
|
val pendingOpenChatId: Long? = null,
|
||||||
val discoverChats: List<DiscoverChatItem> = emptyList(),
|
val discoverChats: List<DiscoverChatItem> = emptyList(),
|
||||||
|
val globalChats: List<DiscoverChatItem> = emptyList(),
|
||||||
val selectedManageChatId: Long? = null,
|
val selectedManageChatId: Long? = null,
|
||||||
val members: List<ChatMemberItem> = emptyList(),
|
val members: List<ChatMemberItem> = emptyList(),
|
||||||
val bans: List<ChatBanItem> = emptyList(),
|
val bans: List<ChatBanItem> = emptyList(),
|
||||||
|
|||||||
@@ -16,16 +16,15 @@ import kotlinx.coroutines.launch
|
|||||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||||
import ru.daemonlord.messenger.domain.chat.repository.ChatSearchRepository
|
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.JoinByInviteUseCase
|
||||||
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
|
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
|
||||||
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
|
import ru.daemonlord.messenger.domain.chat.usecase.RefreshChatsUseCase
|
||||||
import ru.daemonlord.messenger.domain.common.AppError
|
import ru.daemonlord.messenger.domain.common.AppError
|
||||||
import ru.daemonlord.messenger.domain.common.AppResult
|
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.RealtimeManager
|
||||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
|
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
|
||||||
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
|
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
|
||||||
|
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -38,8 +37,7 @@ class ChatListViewModel @Inject constructor(
|
|||||||
private val realtimeManager: RealtimeManager,
|
private val realtimeManager: RealtimeManager,
|
||||||
private val chatRepository: ChatRepository,
|
private val chatRepository: ChatRepository,
|
||||||
private val chatSearchRepository: ChatSearchRepository,
|
private val chatSearchRepository: ChatSearchRepository,
|
||||||
private val accountRepository: AccountRepository,
|
private val searchRepository: SearchRepository,
|
||||||
private val messageRepository: MessageRepository,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val selectedTab = MutableStateFlow(ChatTab.ALL)
|
private val selectedTab = MutableStateFlow(ChatTab.ALL)
|
||||||
@@ -122,17 +120,26 @@ class ChatListViewModel @Inject constructor(
|
|||||||
_uiState.update { it.copy(globalSearchQuery = value) }
|
_uiState.update { it.copy(globalSearchQuery = value) }
|
||||||
val normalized = value.trim()
|
val normalized = value.trim()
|
||||||
if (normalized.length < 2) {
|
if (normalized.length < 2) {
|
||||||
_uiState.update { it.copy(globalUsers = emptyList(), globalMessages = emptyList()) }
|
_uiState.update { it.copy(globalUsers = emptyList(), globalChats = emptyList(), globalMessages = emptyList()) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val usersResult = accountRepository.searchUsers(query = normalized, limit = 10)
|
when (val result = searchRepository.globalSearch(query = normalized, usersLimit = 10, chatsLimit = 10, messagesLimit = 20)) {
|
||||||
val messagesResult = messageRepository.searchMessages(query = normalized, chatId = null)
|
is AppResult.Success -> _uiState.update {
|
||||||
_uiState.update {
|
it.copy(
|
||||||
it.copy(
|
globalUsers = result.data.users,
|
||||||
globalUsers = (usersResult as? AppResult.Success)?.data ?: emptyList(),
|
globalChats = result.data.chats,
|
||||||
globalMessages = (messagesResult as? AppResult.Success)?.data?.take(20) ?: emptyList(),
|
globalMessages = result.data.messages,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
is AppResult.Error -> _uiState.update {
|
||||||
|
it.copy(
|
||||||
|
globalUsers = emptyList(),
|
||||||
|
globalChats = emptyList(),
|
||||||
|
globalMessages = emptyList(),
|
||||||
|
errorMessage = result.reason.toUiMessage(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ Backend покрывает web-функционал почти полность
|
|||||||
|
|
||||||
## 2) Web endpoints not yet fully used on Android
|
## 2) Web endpoints not yet fully used on Android
|
||||||
|
|
||||||
- `GET /api/v1/messages/{message_id}/thread`
|
- `GET /api/v1/messages/{message_id}/thread` (data layer wired, UI thread screen/jump usage pending)
|
||||||
- `GET /api/v1/search` (single global endpoint; Android uses composed search calls)
|
|
||||||
- Contacts endpoints:
|
- Contacts endpoints:
|
||||||
- `GET /api/v1/users/contacts`
|
- `GET /api/v1/users/contacts`
|
||||||
- `POST /api/v1/users/{user_id}/contacts`
|
- `POST /api/v1/users/{user_id}/contacts`
|
||||||
@@ -36,6 +35,5 @@ Backend покрывает web-функционал почти полность
|
|||||||
|
|
||||||
Завершить следующий parity-блок:
|
Завершить следующий parity-блок:
|
||||||
|
|
||||||
- `GET /api/v1/messages/{message_id}/thread`
|
- `GET /api/v1/messages/{message_id}/thread` (UI usage)
|
||||||
- единый `GET /api/v1/search` для полнофункционального Telegram-like поиска
|
|
||||||
- contacts API (`/users/contacts*`) + экран управления контактами
|
- contacts API (`/users/contacts*`) + экран управления контактами
|
||||||
|
|||||||
Reference in New Issue
Block a user