Add channel search and subscription

This commit is contained in:
sevenhill
2026-07-07 22:09:18 +03:00
parent b2766ebbe9
commit 0a42e6f860
14 changed files with 421 additions and 4 deletions
@@ -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)
+85
View File
@@ -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)
+1
View File
@@ -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
+10
View File
@@ -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,