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:
|
||||
- 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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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") {
|
||||
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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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*`) + экран управления контактами
|
||||
|
||||
Reference in New Issue
Block a user