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.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,
|
||||
|
||||
@@ -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<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 {
|
||||
if (!FirebaseBootstrap.ensureInitialized(context)) {
|
||||
Log.w(PushLogTag, "Firebase is not initialized; push device registration skipped")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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) {
|
||||
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.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<MessageDto> = emptyList(),
|
||||
val chatSearchResults: List<MessageDto> = emptyList(),
|
||||
val chatSearchLoading: Boolean = false,
|
||||
val channelSearchResults: List<MaxChannelSearchResultDto> = 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class MaxController(
|
||||
MaxBridgeSyncService syncService,
|
||||
QMaxDbContext db,
|
||||
IHubContext<QMaxHub> hubContext,
|
||||
ChatProjectionService projection,
|
||||
IOptions<QMaxOptions> 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<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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,4 +19,13 @@ public interface IMaxBridgeClient
|
||||
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<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,
|
||||
string? StatusText,
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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(
|
||||
IReadOnlyList<MaxChatUpdate> updates,
|
||||
bool incrementUnread,
|
||||
|
||||
Reference in New Issue
Block a user