Add channel search and subscription
This commit is contained in:
@@ -165,6 +165,7 @@ import xyz.kusoft.qmax.core.push.ForegroundChatTracker
|
|||||||
import xyz.kusoft.qmax.core.model.AttachmentDto
|
import xyz.kusoft.qmax.core.model.AttachmentDto
|
||||||
import xyz.kusoft.qmax.core.model.ChatDto
|
import xyz.kusoft.qmax.core.model.ChatDto
|
||||||
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
||||||
|
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
||||||
import xyz.kusoft.qmax.core.model.MessageDto
|
import xyz.kusoft.qmax.core.model.MessageDto
|
||||||
import xyz.kusoft.qmax.core.model.QMaxSession
|
import xyz.kusoft.qmax.core.model.QMaxSession
|
||||||
import xyz.kusoft.qmax.ui.QMaxUiState
|
import xyz.kusoft.qmax.ui.QMaxUiState
|
||||||
@@ -363,6 +364,14 @@ private fun ChatListScreen(vm: QMaxViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(activeTab, state.searchQuery) {
|
||||||
|
if (activeTab == MainTab.Channels) {
|
||||||
|
vm.searchMaxChannels(state.searchQuery)
|
||||||
|
} else {
|
||||||
|
vm.searchMaxChannels("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
TelegramChatListContent(
|
TelegramChatListContent(
|
||||||
state = state,
|
state = state,
|
||||||
filteredChats = filteredChats,
|
filteredChats = filteredChats,
|
||||||
@@ -377,6 +386,7 @@ private fun ChatListScreen(vm: QMaxViewModel) {
|
|||||||
onSubmitMaxCode = vm::submitMaxCode,
|
onSubmitMaxCode = vm::submitMaxCode,
|
||||||
onLogout = vm::logout,
|
onLogout = vm::logout,
|
||||||
onSearch = vm::updateSearchQuery,
|
onSearch = vm::updateSearchQuery,
|
||||||
|
onSubscribeChannel = vm::subscribeMaxChannel,
|
||||||
onTabSelected = { tab ->
|
onTabSelected = { tab ->
|
||||||
activeTab = tab
|
activeTab = tab
|
||||||
vm.updateSearchQuery("")
|
vm.updateSearchQuery("")
|
||||||
@@ -607,6 +617,7 @@ private fun TelegramChatListContent(
|
|||||||
onSubmitMaxCode: () -> Unit,
|
onSubmitMaxCode: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onSearch: (String) -> Unit,
|
onSearch: (String) -> Unit,
|
||||||
|
onSubscribeChannel: (MaxChannelSearchResultDto) -> Unit,
|
||||||
onTabSelected: (MainTab) -> Unit,
|
onTabSelected: (MainTab) -> Unit,
|
||||||
onOpenChat: (ChatDto) -> Unit,
|
onOpenChat: (ChatDto) -> Unit,
|
||||||
onNewChat: () -> Unit,
|
onNewChat: () -> Unit,
|
||||||
@@ -620,6 +631,9 @@ private fun TelegramChatListContent(
|
|||||||
) {
|
) {
|
||||||
val selectedCount = state.selectedChatIds.size
|
val selectedCount = state.selectedChatIds.size
|
||||||
val listTab = activeTab != MainTab.Settings
|
val listTab = activeTab != MainTab.Settings
|
||||||
|
val showChannelSearch = activeTab == MainTab.Channels &&
|
||||||
|
state.searchQuery.trim().isNotBlank() &&
|
||||||
|
(state.channelSearchLoading || !state.channelSearchError.isNullOrBlank() || state.channelSearchResults.any { !it.isSubscribed })
|
||||||
val headerTitle = when (activeTab) {
|
val headerTitle = when (activeTab) {
|
||||||
MainTab.Chats -> if (state.chatListConnectionIssue) "Соединение..." else "QMAX"
|
MainTab.Chats -> if (state.chatListConnectionIssue) "Соединение..." else "QMAX"
|
||||||
MainTab.Contacts -> "Контакты"
|
MainTab.Contacts -> "Контакты"
|
||||||
@@ -672,9 +686,13 @@ private fun TelegramChatListContent(
|
|||||||
onLogout = onLogout
|
onLogout = onLogout
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ChatListSearchBar(query = state.searchQuery, onQuery = onSearch)
|
ChatListSearchBar(
|
||||||
|
query = state.searchQuery,
|
||||||
|
placeholder = if (activeTab == MainTab.Channels) "Поиск каналов" else "Поиск чатов",
|
||||||
|
onQuery = onSearch
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (listTab && filteredChats.isEmpty()) {
|
if (listTab && filteredChats.isEmpty() && !showChannelSearch) {
|
||||||
if (state.searchQuery.isNotBlank()) {
|
if (state.searchQuery.isNotBlank()) {
|
||||||
EmptyState("Ничего не найдено")
|
EmptyState("Ничего не найдено")
|
||||||
} else {
|
} else {
|
||||||
@@ -685,6 +703,14 @@ private fun TelegramChatListContent(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 118.dp)
|
contentPadding = PaddingValues(bottom = 118.dp)
|
||||||
) {
|
) {
|
||||||
|
if (showChannelSearch) {
|
||||||
|
item {
|
||||||
|
ChannelSearchResults(
|
||||||
|
state = state,
|
||||||
|
onSubscribeChannel = onSubscribeChannel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
items(filteredChats, key = { it.id }) { chat ->
|
items(filteredChats, key = { it.id }) { chat ->
|
||||||
TelegramChatRow(
|
TelegramChatRow(
|
||||||
chat = chat,
|
chat = chat,
|
||||||
@@ -875,7 +901,7 @@ private fun ChatSelectionHeader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) {
|
private fun ChatListSearchBar(query: String, placeholder: String, onQuery: (String) -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
color = Color(0xFFF1F1F3),
|
color = Color(0xFFF1F1F3),
|
||||||
shape = RoundedCornerShape(30.dp),
|
shape = RoundedCornerShape(30.dp),
|
||||||
@@ -893,7 +919,7 @@ private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) {
|
|||||||
value = query,
|
value = query,
|
||||||
onValueChange = onQuery,
|
onValueChange = onQuery,
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text("Поиск чатов", color = Color(0xFF8A8E93), style = MaterialTheme.typography.titleMedium)
|
Text(placeholder, color = Color(0xFF8A8E93), style = MaterialTheme.typography.titleMedium)
|
||||||
},
|
},
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
colors = TextFieldDefaults.colors(
|
colors = TextFieldDefaults.colors(
|
||||||
@@ -1198,6 +1224,82 @@ private fun maxStatusHint(status: MaxBridgeStatusDto?): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChannelSearchResults(
|
||||||
|
state: QMaxUiState,
|
||||||
|
onSubscribeChannel: (MaxChannelSearchResultDto) -> Unit
|
||||||
|
) {
|
||||||
|
val joinableResults = state.channelSearchResults.filterNot { it.isSubscribed }
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
if (state.channelSearchLoading) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 28.dp, vertical = 14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = QMaxBlue, modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text("Поиск каналов", color = QMaxMuted, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.channelSearchError?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = Color(0xFFC62828),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 28.dp, vertical = 10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
joinableResults.forEach { result ->
|
||||||
|
ChannelSearchRow(result = result, onSubscribe = { onSubscribeChannel(result) })
|
||||||
|
}
|
||||||
|
if (joinableResults.isNotEmpty()) {
|
||||||
|
HorizontalDivider(color = Color(0xFFE7EEF3), modifier = Modifier.padding(start = 88.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChannelSearchRow(result: MaxChannelSearchResultDto, onSubscribe: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 9.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Surface(color = Color(0xFFE6F4F1), shape = CircleShape, modifier = Modifier.size(54.dp)) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
result.title.firstOrNull()?.uppercaseChar()?.toString() ?: "#",
|
||||||
|
color = QMaxBlue,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
result.title,
|
||||||
|
color = QMaxText,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
result.description?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = QMaxMuted,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Button(onClick = onSubscribe, enabled = !result.isSubscribed) {
|
||||||
|
Text(if (result.isSubscribed) "В списке" else "Подписаться")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TelegramBottomNavigation(
|
private fun TelegramBottomNavigation(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import xyz.kusoft.qmax.core.model.AuthResponse
|
|||||||
import xyz.kusoft.qmax.core.model.ChatDto
|
import xyz.kusoft.qmax.core.model.ChatDto
|
||||||
import xyz.kusoft.qmax.core.model.ChatPresenceDto
|
import xyz.kusoft.qmax.core.model.ChatPresenceDto
|
||||||
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
||||||
|
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
||||||
import xyz.kusoft.qmax.core.model.MessageDto
|
import xyz.kusoft.qmax.core.model.MessageDto
|
||||||
import xyz.kusoft.qmax.core.model.QMaxSession
|
import xyz.kusoft.qmax.core.model.QMaxSession
|
||||||
import xyz.kusoft.qmax.core.network.QMaxApi
|
import xyz.kusoft.qmax.core.network.QMaxApi
|
||||||
@@ -309,6 +310,18 @@ class QMaxRepository(
|
|||||||
return withFreshSession(session) { api.submitMaxCode(it.serverUrl, it.accessToken, code) }
|
return withFreshSession(session) { api.submitMaxCode(it.serverUrl, it.accessToken, code) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun searchMaxChannels(session: QMaxSession, query: String): List<MaxChannelSearchResultDto> {
|
||||||
|
return withFreshSession(session) { api.searchMaxChannels(it.serverUrl, it.accessToken, query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun subscribeMaxChannel(session: QMaxSession, link: String): ChatDto {
|
||||||
|
val chat = withFreshSession(session) { api.subscribeMaxChannel(it.serverUrl, it.accessToken, link) }
|
||||||
|
val updatedChats = (cachedChats(session).filterNot { it.id == chat.id } + chat)
|
||||||
|
.let(::orderedChats)
|
||||||
|
messageCache.saveChats(session, updatedChats)
|
||||||
|
return chat
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun registerCurrentPushDevice(session: QMaxSession): Boolean {
|
suspend fun registerCurrentPushDevice(session: QMaxSession): Boolean {
|
||||||
if (!FirebaseBootstrap.ensureInitialized(context)) {
|
if (!FirebaseBootstrap.ensureInitialized(context)) {
|
||||||
Log.w(PushLogTag, "Firebase is not initialized; push device registration skipped")
|
Log.w(PushLogTag, "Firebase is not initialized; push device registration skipped")
|
||||||
|
|||||||
@@ -148,6 +148,19 @@ data class BeginMaxLoginRequest(val phoneNumber: String? = null)
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class SubmitMaxCodeRequest(val code: String)
|
data class SubmitMaxCodeRequest(val code: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MaxChannelSearchResultDto(
|
||||||
|
val externalId: String,
|
||||||
|
val title: String,
|
||||||
|
val avatarUrl: String? = null,
|
||||||
|
val chatUrl: String? = null,
|
||||||
|
val isSubscribed: Boolean = false,
|
||||||
|
val description: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SubscribeMaxChannelRequest(val link: String)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RegisterPushDeviceRequest(
|
data class RegisterPushDeviceRequest(
|
||||||
val firebaseToken: String,
|
val firebaseToken: String,
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ import xyz.kusoft.qmax.core.model.EditMessageRequest
|
|||||||
import xyz.kusoft.qmax.core.model.ForwardMessageRequest
|
import xyz.kusoft.qmax.core.model.ForwardMessageRequest
|
||||||
import xyz.kusoft.qmax.core.model.MarkChatReadRequest
|
import xyz.kusoft.qmax.core.model.MarkChatReadRequest
|
||||||
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
||||||
|
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
||||||
import xyz.kusoft.qmax.core.model.MessageDto
|
import xyz.kusoft.qmax.core.model.MessageDto
|
||||||
import xyz.kusoft.qmax.core.model.RegisterPushDeviceRequest
|
import xyz.kusoft.qmax.core.model.RegisterPushDeviceRequest
|
||||||
import xyz.kusoft.qmax.core.model.SendMessageRequest
|
import xyz.kusoft.qmax.core.model.SendMessageRequest
|
||||||
import xyz.kusoft.qmax.core.model.SetReactionRequest
|
import xyz.kusoft.qmax.core.model.SetReactionRequest
|
||||||
|
import xyz.kusoft.qmax.core.model.SubscribeMaxChannelRequest
|
||||||
import xyz.kusoft.qmax.core.model.SubmitMaxCodeRequest
|
import xyz.kusoft.qmax.core.model.SubmitMaxCodeRequest
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -187,6 +189,15 @@ class QMaxApi {
|
|||||||
return post(sessionServerUrl, "/api/max/login/code", token, SubmitMaxCodeRequest(code))
|
return post(sessionServerUrl, "/api/max/login/code", token, SubmitMaxCodeRequest(code))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun searchMaxChannels(sessionServerUrl: String, token: String, query: String): List<MaxChannelSearchResultDto> {
|
||||||
|
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.name())
|
||||||
|
return get(sessionServerUrl, "/api/max/channels/search?q=$encodedQuery", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun subscribeMaxChannel(sessionServerUrl: String, token: String, link: String): ChatDto {
|
||||||
|
return post(sessionServerUrl, "/api/max/channels/subscribe", token, SubscribeMaxChannelRequest(link))
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun registerPushDevice(sessionServerUrl: String, token: String, firebaseToken: String) {
|
suspend fun registerPushDevice(sessionServerUrl: String, token: String, firebaseToken: String) {
|
||||||
postNoContent(sessionServerUrl, "/api/push/devices", token, RegisterPushDeviceRequest(firebaseToken))
|
postNoContent(sessionServerUrl, "/api/push/devices", token, RegisterPushDeviceRequest(firebaseToken))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import xyz.kusoft.qmax.core.QMaxRepository
|
|||||||
import xyz.kusoft.qmax.core.model.AttachmentDto
|
import xyz.kusoft.qmax.core.model.AttachmentDto
|
||||||
import xyz.kusoft.qmax.core.model.ChatDto
|
import xyz.kusoft.qmax.core.model.ChatDto
|
||||||
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
import xyz.kusoft.qmax.core.model.MaxBridgeStatusDto
|
||||||
|
import xyz.kusoft.qmax.core.model.MaxChannelSearchResultDto
|
||||||
import xyz.kusoft.qmax.core.model.MessageDeletedDto
|
import xyz.kusoft.qmax.core.model.MessageDeletedDto
|
||||||
import xyz.kusoft.qmax.core.model.MessageDto
|
import xyz.kusoft.qmax.core.model.MessageDto
|
||||||
import xyz.kusoft.qmax.core.model.QMaxSession
|
import xyz.kusoft.qmax.core.model.QMaxSession
|
||||||
@@ -35,6 +36,9 @@ data class QMaxUiState(
|
|||||||
val messages: List<MessageDto> = emptyList(),
|
val messages: List<MessageDto> = emptyList(),
|
||||||
val chatSearchResults: List<MessageDto> = emptyList(),
|
val chatSearchResults: List<MessageDto> = emptyList(),
|
||||||
val chatSearchLoading: Boolean = false,
|
val chatSearchLoading: Boolean = false,
|
||||||
|
val channelSearchResults: List<MaxChannelSearchResultDto> = emptyList(),
|
||||||
|
val channelSearchLoading: Boolean = false,
|
||||||
|
val channelSearchError: String? = null,
|
||||||
val chatPresenceText: String? = null,
|
val chatPresenceText: String? = null,
|
||||||
val replyTarget: MessageDto? = null,
|
val replyTarget: MessageDto? = null,
|
||||||
val editTarget: MessageDto? = null,
|
val editTarget: MessageDto? = null,
|
||||||
@@ -72,6 +76,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
private var chatRefreshJob: Job? = null
|
private var chatRefreshJob: Job? = null
|
||||||
private var chatRefreshGeneration = 0L
|
private var chatRefreshGeneration = 0L
|
||||||
private var chatSearchJob: Job? = null
|
private var chatSearchJob: Job? = null
|
||||||
|
private var channelSearchJob: Job? = null
|
||||||
private var maxStatusPollJob: Job? = null
|
private var maxStatusPollJob: Job? = null
|
||||||
private var autoLoginJob: Job? = null
|
private var autoLoginJob: Job? = null
|
||||||
private var pushRegisteredForToken: String? = null
|
private var pushRegisteredForToken: String? = null
|
||||||
@@ -114,6 +119,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
messagePollJob?.cancel()
|
messagePollJob?.cancel()
|
||||||
presencePollJob?.cancel()
|
presencePollJob?.cancel()
|
||||||
maxStatusPollJob?.cancel()
|
maxStatusPollJob?.cancel()
|
||||||
|
channelSearchJob?.cancel()
|
||||||
pushRegistrationJob?.cancel()
|
pushRegistrationJob?.cancel()
|
||||||
pushRegistrationInFlightForToken = null
|
pushRegistrationInFlightForToken = null
|
||||||
pushRegisteredForToken = null
|
pushRegisteredForToken = null
|
||||||
@@ -162,6 +168,61 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
|||||||
state.value = state.value.copy(searchQuery = value)
|
state.value = state.value.copy(searchQuery = value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun searchMaxChannels(query: String) {
|
||||||
|
channelSearchJob?.cancel()
|
||||||
|
val trimmed = query.trim()
|
||||||
|
if (trimmed.isBlank()) {
|
||||||
|
state.value = state.value.copy(
|
||||||
|
channelSearchResults = emptyList(),
|
||||||
|
channelSearchLoading = false,
|
||||||
|
channelSearchError = null
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.value = state.value.copy(channelSearchLoading = true, channelSearchError = null)
|
||||||
|
channelSearchJob = viewModelScope.launch {
|
||||||
|
delay(350)
|
||||||
|
val session = state.value.session ?: run {
|
||||||
|
state.value = state.value.copy(channelSearchLoading = false)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
repository.searchMaxChannels(session, trimmed)
|
||||||
|
}.onSuccess { results ->
|
||||||
|
state.value = state.value.copy(
|
||||||
|
channelSearchResults = results,
|
||||||
|
channelSearchLoading = false,
|
||||||
|
channelSearchError = null
|
||||||
|
)
|
||||||
|
}.onFailure { error ->
|
||||||
|
if (error is CancellationException) throw error
|
||||||
|
state.value = state.value.copy(
|
||||||
|
channelSearchResults = emptyList(),
|
||||||
|
channelSearchLoading = false,
|
||||||
|
channelSearchError = error.message ?: "Ошибка поиска каналов"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribeMaxChannel(result: MaxChannelSearchResultDto) = launchLoading(false) {
|
||||||
|
val session = requireSession()
|
||||||
|
val link = result.chatUrl?.takeIf { it.isNotBlank() } ?: result.externalId
|
||||||
|
val chat = repository.subscribeMaxChannel(session, link)
|
||||||
|
state.value = state.value.copy(
|
||||||
|
chats = normalizeChats(state.value.chats.filterNot { it.id == chat.id } + chat),
|
||||||
|
channelSearchResults = state.value.channelSearchResults.map {
|
||||||
|
if (it.externalId == result.externalId || it.chatUrl == result.chatUrl) {
|
||||||
|
it.copy(isSubscribed = true)
|
||||||
|
} else {
|
||||||
|
it
|
||||||
|
}
|
||||||
|
},
|
||||||
|
channelSearchError = null
|
||||||
|
)
|
||||||
|
loadChats()
|
||||||
|
}
|
||||||
|
|
||||||
fun beginChatSelection(chatId: String) {
|
fun beginChatSelection(chatId: String) {
|
||||||
if (chatId.isBlank()) return
|
if (chatId.isBlank()) return
|
||||||
state.value = state.value.copy(selectedChatIds = state.value.selectedChatIds + chatId)
|
state.value = state.value.copy(selectedChatIds = state.value.selectedChatIds + chatId)
|
||||||
|
|||||||
@@ -752,6 +752,37 @@ def parse_chat_id(value: Any) -> int:
|
|||||||
return int(text)
|
return int(text)
|
||||||
|
|
||||||
|
|
||||||
|
def is_channel_link_query(value: str) -> bool:
|
||||||
|
text = value.strip()
|
||||||
|
lower = text.lower()
|
||||||
|
return (
|
||||||
|
"://max.ru/" in lower
|
||||||
|
or "://web.max.ru/" in lower
|
||||||
|
or "/join/" in lower
|
||||||
|
or lower.startswith("join/")
|
||||||
|
or lower.startswith("@")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def channel_search_result(
|
||||||
|
chat: Any,
|
||||||
|
user_map: dict[str, Any] | None = None,
|
||||||
|
me_id: int | None = None,
|
||||||
|
*,
|
||||||
|
subscribed: bool,
|
||||||
|
chat_url: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
user_map = user_map or {}
|
||||||
|
return {
|
||||||
|
"externalId": clean_id(getattr(chat, "id", None)),
|
||||||
|
"title": resolve_chat_title(chat, user_map, me_id),
|
||||||
|
"avatarUrl": resolve_chat_avatar(chat, user_map, me_id),
|
||||||
|
"chatUrl": chat_url or f"pymax://chat/{chat.id}",
|
||||||
|
"isSubscribed": subscribed,
|
||||||
|
"description": first_text(getattr(chat, "description", None), getattr(chat, "about", None)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_send_path(raw: str) -> pathlib.Path:
|
def validate_send_path(raw: str) -> pathlib.Path:
|
||||||
path = pathlib.Path(raw)
|
path = pathlib.Path(raw)
|
||||||
resolved = path.resolve(strict=True)
|
resolved = path.resolve(strict=True)
|
||||||
@@ -877,6 +908,58 @@ async def resolve_chat_url(request: web.Request) -> web.Response:
|
|||||||
return json_response({"success": True, "chatUrl": f"pymax://chat/{chat_id}", "error": None})
|
return json_response({"success": True, "chatUrl": f"pymax://chat/{chat_id}", "error": None})
|
||||||
|
|
||||||
|
|
||||||
|
@route_errors
|
||||||
|
async def channels_search(request: web.Request) -> web.Response:
|
||||||
|
data = await read_json(request)
|
||||||
|
query = str(data.get("query") or "").strip()
|
||||||
|
if not query:
|
||||||
|
return json_response([])
|
||||||
|
|
||||||
|
client = await runtime.get_client()
|
||||||
|
lower_query = query.lower()
|
||||||
|
chats = await fetch_chat_pages(client, CHAT_FETCH_LIMIT)
|
||||||
|
channel_chats = [chat for chat in chats if normalize_chat_kind(chat) == "Channel"]
|
||||||
|
me_id = get_me_user_id(client)
|
||||||
|
user_map = await build_user_map(client, channel_chats)
|
||||||
|
results = [
|
||||||
|
channel_search_result(chat, user_map, me_id, subscribed=True)
|
||||||
|
for chat in channel_chats
|
||||||
|
if lower_query in resolve_chat_title(chat, user_map, me_id).lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
if is_channel_link_query(query) and not any(result.get("chatUrl") == query for result in results):
|
||||||
|
try:
|
||||||
|
resolved = await asyncio.wait_for(client.resolve_group_by_link(query), timeout=10)
|
||||||
|
except Exception:
|
||||||
|
resolved = None
|
||||||
|
if resolved is not None:
|
||||||
|
results.insert(0, channel_search_result(resolved, subscribed=False, chat_url=query))
|
||||||
|
elif not any(result.get("externalId") == query for result in results):
|
||||||
|
results.insert(0, {
|
||||||
|
"externalId": query,
|
||||||
|
"title": query,
|
||||||
|
"avatarUrl": None,
|
||||||
|
"chatUrl": query,
|
||||||
|
"isSubscribed": False,
|
||||||
|
"description": "Канал MAX по ссылке или invite-токену",
|
||||||
|
})
|
||||||
|
|
||||||
|
return json_response(results[:20])
|
||||||
|
|
||||||
|
|
||||||
|
@route_errors
|
||||||
|
async def channels_join(request: web.Request) -> web.Response:
|
||||||
|
data = await read_json(request)
|
||||||
|
link = str(data.get("link") or data.get("chatUrl") or "").strip()
|
||||||
|
if not link:
|
||||||
|
return json_response({"success": False, "error": "link is required"}, status=400)
|
||||||
|
|
||||||
|
client = await runtime.get_client()
|
||||||
|
chat = await client.join_channel(link)
|
||||||
|
result = await normalize_chat_update(client, chat, include_history=True)
|
||||||
|
return json_response(result)
|
||||||
|
|
||||||
|
|
||||||
@route_errors
|
@route_errors
|
||||||
async def chat_presence(_request: web.Request) -> web.Response:
|
async def chat_presence(_request: web.Request) -> web.Response:
|
||||||
return json_response({"isTyping": False, "statusText": None, "updatedAt": utc_now()})
|
return json_response({"isTyping": False, "statusText": None, "updatedAt": utc_now()})
|
||||||
@@ -936,6 +1019,8 @@ def create_app() -> web.Application:
|
|||||||
app.router.add_get("/updates", updates)
|
app.router.add_get("/updates", updates)
|
||||||
app.router.add_post("/chat/history", chat_history)
|
app.router.add_post("/chat/history", chat_history)
|
||||||
app.router.add_post("/chat/resolve-url", resolve_chat_url)
|
app.router.add_post("/chat/resolve-url", resolve_chat_url)
|
||||||
|
app.router.add_post("/channels/search", channels_search)
|
||||||
|
app.router.add_post("/channels/join", channels_join)
|
||||||
app.router.add_post("/chat/presence", chat_presence)
|
app.router.add_post("/chat/presence", chat_presence)
|
||||||
app.router.add_post("/chat/clear-history", disabled_action)
|
app.router.add_post("/chat/clear-history", disabled_action)
|
||||||
app.router.add_post("/chat/delete", disabled_action)
|
app.router.add_post("/chat/delete", disabled_action)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ foreach ($item in @("server", "pymax-worker", "deploy", "Dockerfile.server", "QM
|
|||||||
foreach ($path in @(
|
foreach ($path in @(
|
||||||
"server/QMax.Api/bin",
|
"server/QMax.Api/bin",
|
||||||
"server/QMax.Api/obj",
|
"server/QMax.Api/obj",
|
||||||
|
"deploy/releases",
|
||||||
"pymax-worker/src/__pycache__"
|
"pymax-worker/src/__pycache__"
|
||||||
)) {
|
)) {
|
||||||
$target = Join-Path $staging $path
|
$target = Join-Path $staging $path
|
||||||
|
|||||||
@@ -18,3 +18,13 @@ public sealed record MaxBrowserSnapshotDto(
|
|||||||
string BodyText,
|
string BodyText,
|
||||||
string ScreenshotPngBase64,
|
string ScreenshotPngBase64,
|
||||||
DateTimeOffset CapturedAt);
|
DateTimeOffset CapturedAt);
|
||||||
|
|
||||||
|
public sealed record MaxChannelSearchResultDto(
|
||||||
|
string ExternalId,
|
||||||
|
string Title,
|
||||||
|
string? AvatarUrl,
|
||||||
|
string? ChatUrl,
|
||||||
|
bool IsSubscribed,
|
||||||
|
string? Description);
|
||||||
|
|
||||||
|
public sealed record SubscribeMaxChannelRequest(string Link);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public sealed class MaxController(
|
|||||||
MaxBridgeSyncService syncService,
|
MaxBridgeSyncService syncService,
|
||||||
QMaxDbContext db,
|
QMaxDbContext db,
|
||||||
IHubContext<QMaxHub> hubContext,
|
IHubContext<QMaxHub> hubContext,
|
||||||
|
ChatProjectionService projection,
|
||||||
IOptions<QMaxOptions> options) : ControllerBase
|
IOptions<QMaxOptions> options) : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly QMaxOptions _options = options.Value;
|
private readonly QMaxOptions _options = options.Value;
|
||||||
@@ -69,6 +70,51 @@ public sealed class MaxController(
|
|||||||
return new { changed };
|
return new { changed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("channels/search")]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<MaxChannelSearchResultDto>>> SearchChannels(
|
||||||
|
string q,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
return Array.Empty<MaxChannelSearchResultDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = await maxBridge.SearchChannelsAsync(q.Trim(), cancellationToken);
|
||||||
|
return results.Select(ToDto).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("channels/subscribe")]
|
||||||
|
public async Task<ActionResult<ChatDto>> SubscribeChannel(
|
||||||
|
SubscribeMaxChannelRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Link))
|
||||||
|
{
|
||||||
|
return BadRequest("Channel link is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var update = await maxBridge.JoinChannelAsync(request.Link.Trim(), cancellationToken);
|
||||||
|
if (update is null)
|
||||||
|
{
|
||||||
|
return BadRequest("MAX did not return a channel for this link.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncService.ApplyJoinedChannelAsync(update, cancellationToken);
|
||||||
|
var chat = await db.Chats.FirstOrDefaultAsync(
|
||||||
|
x => x.DeletedAt == null &&
|
||||||
|
(x.ExternalId == update.ExternalId ||
|
||||||
|
(!string.IsNullOrWhiteSpace(update.ChatUrl) && x.WebUrl == update.ChatUrl)),
|
||||||
|
cancellationToken);
|
||||||
|
if (chat is null)
|
||||||
|
{
|
||||||
|
return BadRequest("Channel was joined but was not saved in QMAX.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
|
||||||
|
return projection.ToDto(chat);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken)
|
private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
|
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
|
||||||
@@ -92,4 +138,9 @@ public sealed class MaxController(
|
|||||||
{
|
{
|
||||||
return new MaxBridgeStatusDto(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt);
|
return new MaxBridgeStatusDto(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static MaxChannelSearchResultDto ToDto(MaxChannelSearchResult channel)
|
||||||
|
{
|
||||||
|
return new MaxChannelSearchResultDto(channel.ExternalId, channel.Title, channel.AvatarUrl, channel.ChatUrl, channel.IsSubscribed, channel.Description);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,13 @@ public interface IMaxBridgeClient
|
|||||||
Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
|
Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
|
||||||
Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
|
Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
|
||||||
Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken);
|
Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<MaxChannelSearchResult>> SearchChannelsAsync(string query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyList<MaxChannelSearchResult>>(Array.Empty<MaxChannelSearchResult>());
|
||||||
|
}
|
||||||
|
|
||||||
|
Task<MaxChatUpdate?> JoinChannelAsync(string link, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult<MaxChatUpdate?>(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,3 +65,11 @@ public sealed record MaxChatPresence(
|
|||||||
bool IsTyping,
|
bool IsTyping,
|
||||||
string? StatusText,
|
string? StatusText,
|
||||||
DateTimeOffset UpdatedAt);
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
|
public sealed record MaxChannelSearchResult(
|
||||||
|
string ExternalId,
|
||||||
|
string Title,
|
||||||
|
string? AvatarUrl,
|
||||||
|
string? ChatUrl,
|
||||||
|
bool IsSubscribed,
|
||||||
|
string? Description = null);
|
||||||
|
|||||||
@@ -108,6 +108,30 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
|
|||||||
return Task.FromResult<MaxMediaDownload?>(null);
|
return Task.FromResult<MaxMediaDownload?>(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<MaxChannelSearchResult>> SearchChannelsAsync(string query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
IReadOnlyList<MaxChannelSearchResult> result = string.IsNullOrWhiteSpace(query)
|
||||||
|
? []
|
||||||
|
: [new MaxChannelSearchResult("mock-channel", "Mock channel", null, "mock-channel", false, "Mock MAX channel")];
|
||||||
|
return Task.FromResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<MaxChatUpdate?> JoinChannelAsync(string link, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult<MaxChatUpdate?>(new MaxChatUpdate(
|
||||||
|
"mock-channel",
|
||||||
|
"Mock channel",
|
||||||
|
null,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
[],
|
||||||
|
"Mock channel subscribed",
|
||||||
|
false,
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
null,
|
||||||
|
MockChatUrl("mock-channel"),
|
||||||
|
"Channel"));
|
||||||
|
}
|
||||||
|
|
||||||
private static string MockChatUrl(string externalChatId)
|
private static string MockChatUrl(string externalChatId)
|
||||||
{
|
{
|
||||||
return $"pymax://mock/{Uri.EscapeDataString(externalChatId)}";
|
return $"pymax://mock/{Uri.EscapeDataString(externalChatId)}";
|
||||||
|
|||||||
@@ -160,6 +160,25 @@ public sealed class WorkerMaxBridgeClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<MaxChannelSearchResult>> SearchChannelsAsync(string query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await SendAsync<IReadOnlyList<MaxChannelSearchResult>>(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/channels/search",
|
||||||
|
new { query },
|
||||||
|
cancellationToken)
|
||||||
|
?? Array.Empty<MaxChannelSearchResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<MaxChatUpdate?> JoinChannelAsync(string link, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await SendAsync<MaxChatUpdate>(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/channels/join",
|
||||||
|
new { link },
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<T?> SendAsync<T>(HttpMethod method, string path, object? body, CancellationToken cancellationToken)
|
private async Task<T?> SendAsync<T>(HttpMethod method, string path, object? body, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -93,6 +93,16 @@ public sealed class MaxBridgeSyncService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> ApplyJoinedChannelAsync(MaxChatUpdate update, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await ApplyUpdatesAsync(
|
||||||
|
[update],
|
||||||
|
incrementUnread: false,
|
||||||
|
sendPushNotifications: false,
|
||||||
|
cancellationToken,
|
||||||
|
allowGenericMediaHistoryFetch: false);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<int> ApplyUpdatesAsync(
|
private async Task<int> ApplyUpdatesAsync(
|
||||||
IReadOnlyList<MaxChatUpdate> updates,
|
IReadOnlyList<MaxChatUpdate> updates,
|
||||||
bool incrementUnread,
|
bool incrementUnread,
|
||||||
|
|||||||
Reference in New Issue
Block a user