diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index 05155b3..b1d78c2 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -165,6 +165,7 @@ import xyz.kusoft.qmax.core.push.ForegroundChatTracker import xyz.kusoft.qmax.core.model.AttachmentDto import xyz.kusoft.qmax.core.model.ChatDto 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.QMaxSession 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( state = state, filteredChats = filteredChats, @@ -377,6 +386,7 @@ private fun ChatListScreen(vm: QMaxViewModel) { onSubmitMaxCode = vm::submitMaxCode, onLogout = vm::logout, onSearch = vm::updateSearchQuery, + onSubscribeChannel = vm::subscribeMaxChannel, onTabSelected = { tab -> activeTab = tab vm.updateSearchQuery("") @@ -607,6 +617,7 @@ private fun TelegramChatListContent( onSubmitMaxCode: () -> Unit, onLogout: () -> Unit, onSearch: (String) -> Unit, + onSubscribeChannel: (MaxChannelSearchResultDto) -> Unit, onTabSelected: (MainTab) -> Unit, onOpenChat: (ChatDto) -> Unit, onNewChat: () -> Unit, @@ -620,6 +631,9 @@ private fun TelegramChatListContent( ) { val selectedCount = state.selectedChatIds.size 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) { MainTab.Chats -> if (state.chatListConnectionIssue) "Соединение..." else "QMAX" MainTab.Contacts -> "Контакты" @@ -672,9 +686,13 @@ private fun TelegramChatListContent( onLogout = onLogout ) } 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()) { EmptyState("Ничего не найдено") } else { @@ -685,6 +703,14 @@ private fun TelegramChatListContent( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 118.dp) ) { + if (showChannelSearch) { + item { + ChannelSearchResults( + state = state, + onSubscribeChannel = onSubscribeChannel + ) + } + } items(filteredChats, key = { it.id }) { chat -> TelegramChatRow( chat = chat, @@ -875,7 +901,7 @@ private fun ChatSelectionHeader( } @Composable -private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) { +private fun ChatListSearchBar(query: String, placeholder: String, onQuery: (String) -> Unit) { Surface( color = Color(0xFFF1F1F3), shape = RoundedCornerShape(30.dp), @@ -893,7 +919,7 @@ private fun ChatListSearchBar(query: String, onQuery: (String) -> Unit) { value = query, onValueChange = onQuery, placeholder = { - Text("Поиск чатов", color = Color(0xFF8A8E93), style = MaterialTheme.typography.titleMedium) + Text(placeholder, color = Color(0xFF8A8E93), style = MaterialTheme.typography.titleMedium) }, singleLine = true, 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 private fun TelegramBottomNavigation( modifier: Modifier = Modifier, diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt index 1420823..7c35f1a 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt @@ -27,6 +27,7 @@ import xyz.kusoft.qmax.core.model.AuthResponse import xyz.kusoft.qmax.core.model.ChatDto import xyz.kusoft.qmax.core.model.ChatPresenceDto 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.QMaxSession import xyz.kusoft.qmax.core.network.QMaxApi @@ -309,6 +310,18 @@ class QMaxRepository( return withFreshSession(session) { api.submitMaxCode(it.serverUrl, it.accessToken, code) } } + suspend fun searchMaxChannels(session: QMaxSession, query: String): List { + 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 { if (!FirebaseBootstrap.ensureInitialized(context)) { Log.w(PushLogTag, "Firebase is not initialized; push device registration skipped") diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt index 7e9ee3e..e82030a 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/model/Models.kt @@ -148,6 +148,19 @@ data class BeginMaxLoginRequest(val phoneNumber: String? = null) @Serializable 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 data class RegisterPushDeviceRequest( val firebaseToken: String, diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt index 11e49f9..5c4fae8 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt @@ -31,10 +31,12 @@ import xyz.kusoft.qmax.core.model.EditMessageRequest import xyz.kusoft.qmax.core.model.ForwardMessageRequest import xyz.kusoft.qmax.core.model.MarkChatReadRequest 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.RegisterPushDeviceRequest import xyz.kusoft.qmax.core.model.SendMessageRequest import xyz.kusoft.qmax.core.model.SetReactionRequest +import xyz.kusoft.qmax.core.model.SubscribeMaxChannelRequest import xyz.kusoft.qmax.core.model.SubmitMaxCodeRequest import java.io.IOException import java.io.File @@ -187,6 +189,15 @@ class QMaxApi { return post(sessionServerUrl, "/api/max/login/code", token, SubmitMaxCodeRequest(code)) } + suspend fun searchMaxChannels(sessionServerUrl: String, token: String, query: String): List { + 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) { postNoContent(sessionServerUrl, "/api/push/devices", token, RegisterPushDeviceRequest(firebaseToken)) } diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt index 92f3dec..68a207a 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt @@ -17,6 +17,7 @@ import xyz.kusoft.qmax.core.QMaxRepository import xyz.kusoft.qmax.core.model.AttachmentDto import xyz.kusoft.qmax.core.model.ChatDto 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.MessageDto import xyz.kusoft.qmax.core.model.QMaxSession @@ -35,6 +36,9 @@ data class QMaxUiState( val messages: List = emptyList(), val chatSearchResults: List = emptyList(), val chatSearchLoading: Boolean = false, + val channelSearchResults: List = emptyList(), + val channelSearchLoading: Boolean = false, + val channelSearchError: String? = null, val chatPresenceText: String? = null, val replyTarget: MessageDto? = null, val editTarget: MessageDto? = null, @@ -72,6 +76,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { private var chatRefreshJob: Job? = null private var chatRefreshGeneration = 0L private var chatSearchJob: Job? = null + private var channelSearchJob: Job? = null private var maxStatusPollJob: Job? = null private var autoLoginJob: Job? = null private var pushRegisteredForToken: String? = null @@ -114,6 +119,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { messagePollJob?.cancel() presencePollJob?.cancel() maxStatusPollJob?.cancel() + channelSearchJob?.cancel() pushRegistrationJob?.cancel() pushRegistrationInFlightForToken = null pushRegisteredForToken = null @@ -162,6 +168,61 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { 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) { if (chatId.isBlank()) return state.value = state.value.copy(selectedChatIds = state.value.selectedChatIds + chatId) diff --git a/pymax-worker/src/server.py b/pymax-worker/src/server.py index 5835e7d..20c2428 100644 --- a/pymax-worker/src/server.py +++ b/pymax-worker/src/server.py @@ -752,6 +752,37 @@ def parse_chat_id(value: Any) -> int: 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: path = pathlib.Path(raw) 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}) +@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 async def chat_presence(_request: web.Request) -> web.Response: 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_post("/chat/history", chat_history) 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/clear-history", disabled_action) app.router.add_post("/chat/delete", disabled_action) diff --git a/scripts/deploy-rpi.ps1 b/scripts/deploy-rpi.ps1 index d3a5298..4b1c60f 100644 --- a/scripts/deploy-rpi.ps1 +++ b/scripts/deploy-rpi.ps1 @@ -30,6 +30,7 @@ foreach ($item in @("server", "pymax-worker", "deploy", "Dockerfile.server", "QM foreach ($path in @( "server/QMax.Api/bin", "server/QMax.Api/obj", + "deploy/releases", "pymax-worker/src/__pycache__" )) { $target = Join-Path $staging $path diff --git a/server/QMax.Api/Contracts/MaxContracts.cs b/server/QMax.Api/Contracts/MaxContracts.cs index a1475af..9470385 100644 --- a/server/QMax.Api/Contracts/MaxContracts.cs +++ b/server/QMax.Api/Contracts/MaxContracts.cs @@ -18,3 +18,13 @@ public sealed record MaxBrowserSnapshotDto( string BodyText, string ScreenshotPngBase64, DateTimeOffset CapturedAt); + +public sealed record MaxChannelSearchResultDto( + string ExternalId, + string Title, + string? AvatarUrl, + string? ChatUrl, + bool IsSubscribed, + string? Description); + +public sealed record SubscribeMaxChannelRequest(string Link); diff --git a/server/QMax.Api/Controllers/MaxController.cs b/server/QMax.Api/Controllers/MaxController.cs index 94962c5..a6f49ee 100644 --- a/server/QMax.Api/Controllers/MaxController.cs +++ b/server/QMax.Api/Controllers/MaxController.cs @@ -21,6 +21,7 @@ public sealed class MaxController( MaxBridgeSyncService syncService, QMaxDbContext db, IHubContext hubContext, + ChatProjectionService projection, IOptions options) : ControllerBase { private readonly QMaxOptions _options = options.Value; @@ -69,6 +70,51 @@ public sealed class MaxController( return new { changed }; } + [HttpGet("channels/search")] + public async Task>> SearchChannels( + string q, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(q)) + { + return Array.Empty(); + } + + var results = await maxBridge.SearchChannelsAsync(q.Trim(), cancellationToken); + return results.Select(ToDto).ToArray(); + } + + [HttpPost("channels/subscribe")] + public async Task> 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) { 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); } + + private static MaxChannelSearchResultDto ToDto(MaxChannelSearchResult channel) + { + return new MaxChannelSearchResultDto(channel.ExternalId, channel.Title, channel.AvatarUrl, channel.ChatUrl, channel.IsSubscribed, channel.Description); + } } diff --git a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs index 70bba25..9e1f5ea 100644 --- a/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/IMaxBridgeClient.cs @@ -19,4 +19,13 @@ public interface IMaxBridgeClient Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken); Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken); Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken); + Task> SearchChannelsAsync(string query, CancellationToken cancellationToken) + { + return Task.FromResult>(Array.Empty()); + } + + Task JoinChannelAsync(string link, CancellationToken cancellationToken) + { + return Task.FromResult(null); + } } diff --git a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs index df4d848..32e4dc9 100644 --- a/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs +++ b/server/QMax.Api/Infrastructure/Max/MaxBridgeModels.cs @@ -65,3 +65,11 @@ public sealed record MaxChatPresence( bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt); + +public sealed record MaxChannelSearchResult( + string ExternalId, + string Title, + string? AvatarUrl, + string? ChatUrl, + bool IsSubscribed, + string? Description = null); diff --git a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs index 68399e2..de2c373 100644 --- a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs @@ -108,6 +108,30 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient return Task.FromResult(null); } + public Task> SearchChannelsAsync(string query, CancellationToken cancellationToken) + { + IReadOnlyList result = string.IsNullOrWhiteSpace(query) + ? [] + : [new MaxChannelSearchResult("mock-channel", "Mock channel", null, "mock-channel", false, "Mock MAX channel")]; + return Task.FromResult(result); + } + + public Task JoinChannelAsync(string link, CancellationToken cancellationToken) + { + return Task.FromResult(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) { return $"pymax://mock/{Uri.EscapeDataString(externalChatId)}"; diff --git a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs index cbe5526..25fcc7a 100644 --- a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs @@ -160,6 +160,25 @@ public sealed class WorkerMaxBridgeClient( } } + public async Task> SearchChannelsAsync(string query, CancellationToken cancellationToken) + { + return await SendAsync>( + HttpMethod.Post, + "/channels/search", + new { query }, + cancellationToken) + ?? Array.Empty(); + } + + public async Task JoinChannelAsync(string link, CancellationToken cancellationToken) + { + return await SendAsync( + HttpMethod.Post, + "/channels/join", + new { link }, + cancellationToken); + } + private async Task SendAsync(HttpMethod method, string path, object? body, CancellationToken cancellationToken) { try diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index 5a38544..3f72215 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -93,6 +93,16 @@ public sealed class MaxBridgeSyncService( } } + public async Task ApplyJoinedChannelAsync(MaxChatUpdate update, CancellationToken cancellationToken) + { + return await ApplyUpdatesAsync( + [update], + incrementUnread: false, + sendPushNotifications: false, + cancellationToken, + allowGenericMediaHistoryFetch: false); + } + private async Task ApplyUpdatesAsync( IReadOnlyList updates, bool incrementUnread,