Fix async chat actions and channel sync

This commit is contained in:
sevenhill
2026-07-06 21:38:35 +03:00
parent 84bf7e96c9
commit 7aae5acda3
6 changed files with 1220 additions and 89 deletions
@@ -328,21 +328,35 @@ private enum class ChatBulkUiAction {
Delete Delete
} }
private enum class MainTab {
Chats,
Contacts,
Channels,
Settings
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ChatListScreen(vm: QMaxViewModel) { private fun ChatListScreen(vm: QMaxViewModel) {
val state by vm.state val state by vm.state
var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) }
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
var selectionMenuOpen by remember { mutableStateOf(false) } var selectionMenuOpen by remember { mutableStateOf(false) }
var pendingBulkAction by remember { mutableStateOf<ChatBulkUiAction?>(null) } var pendingBulkAction by remember { mutableStateOf<ChatBulkUiAction?>(null) }
var searchMode by remember { mutableStateOf(false) } var searchMode by remember { mutableStateOf(false) }
var newChatOpen by remember { mutableStateOf(false) } var newChatOpen by remember { mutableStateOf(false) }
val filteredChats = remember(state.chats, state.searchQuery) { val filteredChats = remember(state.chats, state.searchQuery, activeTab) {
val query = state.searchQuery.trim() val query = state.searchQuery.trim()
val source = when (activeTab) {
MainTab.Chats -> state.chats.filterNot { it.isChannel() }
MainTab.Channels -> state.chats.filter { it.isChannel() }
MainTab.Contacts,
MainTab.Settings -> emptyList()
}
if (query.isBlank()) { if (query.isBlank()) {
state.chats source
} else { } else {
state.chats.filter { source.filter {
it.title.contains(query, ignoreCase = true) || it.title.contains(query, ignoreCase = true) ||
(it.lastMessagePreview?.contains(query, ignoreCase = true) == true) (it.lastMessagePreview?.contains(query, ignoreCase = true) == true)
} }
@@ -352,12 +366,18 @@ private fun ChatListScreen(vm: QMaxViewModel) {
TelegramChatListContent( TelegramChatListContent(
state = state, state = state,
filteredChats = filteredChats, filteredChats = filteredChats,
activeTab = activeTab,
menuOpen = menuOpen, menuOpen = menuOpen,
onMenuOpenChange = { menuOpen = it }, onMenuOpenChange = { menuOpen = it },
onRefresh = vm::loadChats, onRefresh = vm::loadChats,
onCheckUpdates = vm::checkArgusUpdate, onCheckUpdates = vm::checkArgusUpdate,
onLogout = vm::logout, onLogout = vm::logout,
onSearch = vm::updateSearchQuery, onSearch = vm::updateSearchQuery,
onTabSelected = { tab ->
activeTab = tab
vm.updateSearchQuery("")
vm.clearChatSelection()
},
onOpenChat = vm::openChat, onOpenChat = vm::openChat,
onNewChat = { newChatOpen = true }, onNewChat = { newChatOpen = true },
onCacheAvatar = vm::cacheAvatar, onCacheAvatar = vm::cacheAvatar,
@@ -580,12 +600,14 @@ private fun ChatListScreen(vm: QMaxViewModel) {
private fun TelegramChatListContent( private fun TelegramChatListContent(
state: QMaxUiState, state: QMaxUiState,
filteredChats: List<ChatDto>, filteredChats: List<ChatDto>,
activeTab: MainTab,
menuOpen: Boolean, menuOpen: Boolean,
onMenuOpenChange: (Boolean) -> Unit, onMenuOpenChange: (Boolean) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onCheckUpdates: () -> Unit, onCheckUpdates: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onSearch: (String) -> Unit, onSearch: (String) -> Unit,
onTabSelected: (MainTab) -> Unit,
onOpenChat: (ChatDto) -> Unit, onOpenChat: (ChatDto) -> Unit,
onNewChat: () -> Unit, onNewChat: () -> Unit,
onCacheAvatar: (String) -> Unit, onCacheAvatar: (String) -> Unit,
@@ -598,6 +620,19 @@ private fun TelegramChatListContent(
onDeleteSelectedChats: () -> Unit onDeleteSelectedChats: () -> Unit
) { ) {
val selectedCount = state.selectedChatIds.size val selectedCount = state.selectedChatIds.size
val listTab = activeTab != MainTab.Settings
val headerTitle = when (activeTab) {
MainTab.Chats -> if (state.chatListConnectionIssue) "Соединение..." else "QMAX"
MainTab.Contacts -> "Контакты"
MainTab.Channels -> "Каналы"
MainTab.Settings -> "Настройки"
}
val emptyText = when (activeTab) {
MainTab.Chats -> null
MainTab.Contacts -> "Контактов пока нет"
MainTab.Channels -> "Каналов пока нет"
MainTab.Settings -> null
}
Box(Modifier.fillMaxSize().background(Color.White)) { Box(Modifier.fillMaxSize().background(Color.White)) {
Column( Column(
Modifier Modifier
@@ -616,30 +651,31 @@ private fun TelegramChatListContent(
) )
} else { } else {
TelegramLikeHeader( TelegramLikeHeader(
title = if (state.chatListConnectionIssue) "Соединение..." else "QMAX", title = headerTitle,
menuOpen = menuOpen, menuOpen = menuOpen,
onMenuOpenChange = onMenuOpenChange, onMenuOpenChange = onMenuOpenChange,
onRefresh = onRefresh, onRefresh = onRefresh,
onCheckUpdates = onCheckUpdates,
onLogout = onLogout onLogout = onLogout
) )
} }
ChatListSearchBar(query = state.searchQuery, onQuery = onSearch)
state.updateMessage?.let {
Text(
it,
color = QMaxMuted,
modifier = Modifier.fillMaxWidth().padding(horizontal = 28.dp, vertical = 6.dp)
)
}
ErrorLine(state.error) ErrorLine(state.error)
if (filteredChats.isEmpty()) { if (activeTab == MainTab.Settings) {
SettingsTabContent(
state = state,
onCheckUpdates = onCheckUpdates,
onRefresh = onRefresh,
onLogout = onLogout
)
} else {
ChatListSearchBar(query = state.searchQuery, onQuery = onSearch)
}
if (listTab && filteredChats.isEmpty()) {
if (state.searchQuery.isNotBlank()) { if (state.searchQuery.isNotBlank()) {
EmptyState("Ничего не найдено") EmptyState("Ничего не найдено")
} else { } else {
Spacer(Modifier.fillMaxSize()) emptyText?.let { EmptyState(it) } ?: Spacer(Modifier.fillMaxSize())
} }
} else { } else if (listTab) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 118.dp) contentPadding = PaddingValues(bottom = 118.dp)
@@ -667,6 +703,7 @@ private fun TelegramChatListContent(
} }
} }
if (activeTab == MainTab.Chats) {
ChatListFloatingActions( ChatListFloatingActions(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
@@ -674,12 +711,16 @@ private fun TelegramChatListContent(
.padding(end = 22.dp, bottom = 96.dp), .padding(end = 22.dp, bottom = 96.dp),
onNewChat = onNewChat onNewChat = onNewChat
) )
}
TelegramBottomNavigation( TelegramBottomNavigation(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.navigationBarsPadding() .navigationBarsPadding()
.padding(horizontal = 28.dp, vertical = 10.dp), .padding(horizontal = 28.dp, vertical = 10.dp),
unreadCount = state.chats.sumOf { it.unreadCount } activeTab = activeTab,
chatUnreadCount = state.chats.filterNot { it.isChannel() }.sumOf { it.unreadCount },
channelUnreadCount = state.chats.filter { it.isChannel() }.sumOf { it.unreadCount },
onTabSelected = onTabSelected
) )
} }
} }
@@ -690,7 +731,6 @@ private fun TelegramLikeHeader(
menuOpen: Boolean, menuOpen: Boolean,
onMenuOpenChange: (Boolean) -> Unit, onMenuOpenChange: (Boolean) -> Unit,
onRefresh: () -> Unit, onRefresh: () -> Unit,
onCheckUpdates: () -> Unit,
onLogout: () -> Unit onLogout: () -> Unit
) { ) {
Row( Row(
@@ -723,13 +763,6 @@ private fun TelegramLikeHeader(
onRefresh() onRefresh()
} }
) )
DropdownMenuItem(
text = { Text("Проверить обновления") },
onClick = {
onMenuOpenChange(false)
onCheckUpdates()
}
)
DropdownMenuItem( DropdownMenuItem(
text = { Text("Выйти") }, text = { Text("Выйти") },
onClick = { onClick = {
@@ -1033,7 +1066,64 @@ private fun ChatListFloatingActions(modifier: Modifier = Modifier, onNewChat: ()
} }
@Composable @Composable
private fun TelegramBottomNavigation(modifier: Modifier = Modifier, unreadCount: Int) { private fun SettingsTabContent(
state: QMaxUiState,
onCheckUpdates: () -> Unit,
onRefresh: () -> Unit,
onLogout: () -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 28.dp, top = 14.dp, end = 28.dp, bottom = 118.dp)
) {
item {
Text("Версия приложения", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = QMaxText)
Spacer(Modifier.height(8.dp))
Text(BuildConfig.VERSION_NAME, style = MaterialTheme.typography.bodyLarge, color = QMaxMuted)
Spacer(Modifier.height(12.dp))
Button(onClick = onCheckUpdates, enabled = !state.loading) {
Text(if (state.loading) "Проверяю..." else "Проверить обновление")
}
state.updateMessage?.let {
Spacer(Modifier.height(10.dp))
Text(it, color = QMaxMuted, style = MaterialTheme.typography.bodyMedium)
}
Spacer(Modifier.height(22.dp))
HorizontalDivider(color = Color(0xFFE7EEF3))
}
item {
Spacer(Modifier.height(22.dp))
Text("MAX", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = QMaxText)
Spacer(Modifier.height(8.dp))
Text(
state.maxStatus?.status ?: "Статус неизвестен",
style = MaterialTheme.typography.bodyLarge,
color = QMaxMuted
)
state.maxStatus?.lastError?.takeIf { it.isNotBlank() }?.let {
Spacer(Modifier.height(8.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = Color(0xFFC62828))
}
Spacer(Modifier.height(12.dp))
Button(onClick = onRefresh, enabled = !state.loading) {
Text("Обновить чаты")
}
Spacer(Modifier.height(8.dp))
TextButton(onClick = onLogout) {
Text("Выйти")
}
}
}
}
@Composable
private fun TelegramBottomNavigation(
modifier: Modifier = Modifier,
activeTab: MainTab,
chatUnreadCount: Int,
channelUnreadCount: Int,
onTabSelected: (MainTab) -> Unit
) {
Surface( Surface(
color = Color.White, color = Color.White,
shape = RoundedCornerShape(30.dp), shape = RoundedCornerShape(30.dp),
@@ -1046,24 +1136,29 @@ private fun TelegramBottomNavigation(modifier: Modifier = Modifier, unreadCount:
) { ) {
TelegramBottomNavItem( TelegramBottomNavItem(
label = "Чаты", label = "Чаты",
selected = true, selected = activeTab == MainTab.Chats,
icon = { Icon(Icons.Filled.Chat, contentDescription = null) }, icon = { Icon(Icons.Filled.Chat, contentDescription = null) },
badge = unreadCount.takeIf { it > 0 } badge = chatUnreadCount.takeIf { it > 0 },
onClick = { onTabSelected(MainTab.Chats) }
) )
TelegramBottomNavItem( TelegramBottomNavItem(
label = "Контакты", label = "Контакты",
selected = false, selected = activeTab == MainTab.Contacts,
icon = { Icon(Icons.Filled.Person, contentDescription = null) } icon = { Icon(Icons.Filled.Person, contentDescription = null) },
onClick = { onTabSelected(MainTab.Contacts) }
) )
TelegramBottomNavItem( TelegramBottomNavItem(
label = "Каналы", label = "Каналы",
selected = false, selected = activeTab == MainTab.Channels,
icon = { Icon(Icons.Filled.Campaign, contentDescription = null) } icon = { Icon(Icons.Filled.Campaign, contentDescription = null) },
badge = channelUnreadCount.takeIf { it > 0 },
onClick = { onTabSelected(MainTab.Channels) }
) )
TelegramBottomNavItem( TelegramBottomNavItem(
label = "Настройки", label = "Настройки",
selected = false, selected = activeTab == MainTab.Settings,
icon = { Icon(Icons.Filled.Settings, contentDescription = null) } icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
onClick = { onTabSelected(MainTab.Settings) }
) )
} }
} }
@@ -1074,13 +1169,15 @@ private fun RowScope.TelegramBottomNavItem(
label: String, label: String,
selected: Boolean, selected: Boolean,
icon: @Composable () -> Unit, icon: @Composable () -> Unit,
badge: Int? = null badge: Int? = null,
onClick: () -> Unit
) { ) {
val tint = if (selected) Color(0xFF20BFA9) else QMaxText val tint = if (selected) Color(0xFF20BFA9) else QMaxText
Column( Column(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.clip(RoundedCornerShape(26.dp)) .clip(RoundedCornerShape(26.dp))
.clickable(onClick = onClick)
.background(if (selected) Color(0xFFE6FAF5) else Color.Transparent) .background(if (selected) Color(0xFFE6FAF5) else Color.Transparent)
.padding(vertical = 6.dp), .padding(vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
@@ -1116,6 +1213,10 @@ private fun RowScope.TelegramBottomNavItem(
} }
} }
private fun ChatDto.isChannel(): Boolean {
return kind.equals("Channel", ignoreCase = true)
}
@Composable @Composable
private fun NewChatDialog(vm: QMaxViewModel, onDismiss: () -> Unit, onCreate: () -> Unit) { private fun NewChatDialog(vm: QMaxViewModel, onDismiss: () -> Unit, onCreate: () -> Unit) {
val state by vm.state val state by vm.state
@@ -27,7 +27,8 @@ public sealed record MaxChatUpdate(
bool? LastMessageIsOutgoing = null, bool? LastMessageIsOutgoing = null,
DateTimeOffset? LastMessageAt = null, DateTimeOffset? LastMessageAt = null,
string? LastMessageTimeText = null, string? LastMessageTimeText = null,
string? ChatUrl = null); string? ChatUrl = null,
string? Kind = null);
public sealed record MaxMessageUpdate( public sealed record MaxMessageUpdate(
string ExternalId, string ExternalId,
@@ -80,12 +80,13 @@ public sealed class MaxBridgeSyncService(
var createdMessages = new List<(Chat Chat, Message Message)>(); var createdMessages = new List<(Chat Chat, Message Message)>();
var updatedMessages = new List<(Chat Chat, Message Message)>(); var updatedMessages = new List<(Chat Chat, Message Message)>();
var previewNotificationMessages = new List<(Chat Chat, Message Message)>(); var previewNotificationMessages = new List<(Chat Chat, Message Message)>();
var genericMediaHistoryRequests = new List<(string ExternalChatId, string? ChatUrl, DateTimeOffset PreviewAt)>(); var genericMediaHistoryRequests = new List<(string ExternalChatId, string? ChatUrl, DateTimeOffset PreviewAt, bool IncrementUnread)>();
var trackedChatsByExternalId = new Dictionary<string, Chat>(StringComparer.Ordinal); var trackedChatsByExternalId = new Dictionary<string, Chat>(StringComparer.Ordinal);
foreach (var update in updates) foreach (var update in updates)
{ {
var nextWebUrl = CleanWebUrl(update.ChatUrl); var nextWebUrl = CleanWebUrl(update.ChatUrl);
var nextKind = ResolveChatKind(update.Kind);
if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat)) if (!trackedChatsByExternalId.TryGetValue(update.ExternalId, out var chat))
{ {
chat = await db.Chats chat = await db.Chats
@@ -95,6 +96,10 @@ public sealed class MaxBridgeSyncService(
chat = await db.Chats chat = await db.Chats
.FirstOrDefaultAsync(x => x.WebUrl == nextWebUrl, cancellationToken); .FirstOrDefaultAsync(x => x.WebUrl == nextWebUrl, cancellationToken);
} }
if (chat is null)
{
chat = await FindDeletedChatTombstoneAsync(db, update, nextWebUrl, cancellationToken);
}
} }
if (chat?.DeletedAt is not null) if (chat?.DeletedAt is not null)
@@ -112,7 +117,7 @@ public sealed class MaxBridgeSyncService(
Title = update.Title, Title = update.Title,
AvatarUrl = update.AvatarUrl, AvatarUrl = update.AvatarUrl,
WebUrl = nextWebUrl, WebUrl = nextWebUrl,
Kind = ChatKind.MaxDialog Kind = nextKind ?? ChatKind.MaxDialog
}; };
db.Chats.Add(chat); db.Chats.Add(chat);
changed++; changed++;
@@ -149,6 +154,12 @@ public sealed class MaxBridgeSyncService(
metadataChanged = true; metadataChanged = true;
} }
if (nextKind is { } resolvedKind && chat.Kind != resolvedKind)
{
chat.Kind = resolvedKind;
metadataChanged = true;
}
if (metadataChanged) if (metadataChanged)
{ {
chat.UpdatedAt = DateTimeOffset.UtcNow; chat.UpdatedAt = DateTimeOffset.UtcNow;
@@ -189,7 +200,9 @@ public sealed class MaxBridgeSyncService(
chat, chat,
chatWasCreated, chatWasCreated,
incrementUnread, incrementUnread,
lastKnownMessage); lastKnownMessage,
createPreviewMessages: chat.Kind != ChatKind.Channel,
incrementGenericMediaUnread: chat.Kind == ChatKind.Channel);
if (previewResult.Message is not null) if (previewResult.Message is not null)
{ {
if (!await HasPreviewPlaceholderAsync(db, chat.Id, previewResult.Message, cancellationToken)) if (!await HasPreviewPlaceholderAsync(db, chat.Id, previewResult.Message, cancellationToken))
@@ -203,12 +216,21 @@ public sealed class MaxBridgeSyncService(
previewNotificationMessages.Add((chat, previewResult.NotificationMessage)); previewNotificationMessages.Add((chat, previewResult.NotificationMessage));
} }
var shouldFetchHistory = previewResult.ShouldFetchHistory ||
(chat.Kind == ChatKind.Channel &&
previewResult.PreviewAt is not null &&
update.Messages.Count == 0 &&
HasListClockTime(update.LastMessageTimeText));
if (allowGenericMediaHistoryFetch && if (allowGenericMediaHistoryFetch &&
previewResult.ShouldFetchHistory && shouldFetchHistory &&
previewResult.PreviewAt is { } previewAt && previewResult.PreviewAt is { } previewAt &&
!await HasMessageAtOrAfterAsync(db, chat.Id, previewAt, cancellationToken)) !await HasMessageAtOrAfterAsync(db, chat.Id, previewAt, cancellationToken))
{ {
genericMediaHistoryRequests.Add((update.ExternalId, nextWebUrl ?? chat.WebUrl, previewAt)); genericMediaHistoryRequests.Add((
update.ExternalId,
nextWebUrl ?? chat.WebUrl,
previewAt,
IncrementUnread: chat.Kind != ChatKind.Channel));
} }
if (previewResult.Changed) if (previewResult.Changed)
@@ -372,7 +394,7 @@ public sealed class MaxBridgeSyncService(
{ {
var dto = projection.ToDto(message); var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken); await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
if (sendPushNotifications && message.Direction == MessageDirection.Incoming) if (sendPushNotifications && message.Direction == MessageDirection.Incoming && chat.Kind != ChatKind.Channel)
{ {
await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken); await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken);
} }
@@ -380,7 +402,7 @@ public sealed class MaxBridgeSyncService(
foreach (var (chat, message) in previewNotificationMessages) foreach (var (chat, message) in previewNotificationMessages)
{ {
if (sendPushNotifications && message.Direction == MessageDirection.Incoming) if (sendPushNotifications && message.Direction == MessageDirection.Incoming && chat.Kind != ChatKind.Channel)
{ {
await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken); await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken);
} }
@@ -413,7 +435,7 @@ public sealed class MaxBridgeSyncService(
{ {
changed += await ApplyUpdatesAsync( changed += await ApplyUpdatesAsync(
[history], [history],
incrementUnread, incrementUnread && request.IncrementUnread,
sendPushNotifications, sendPushNotifications,
cancellationToken, cancellationToken,
allowGenericMediaHistoryFetch: false); allowGenericMediaHistoryFetch: false);
@@ -433,7 +455,9 @@ public sealed class MaxBridgeSyncService(
Chat chat, Chat chat,
bool chatWasCreated, bool chatWasCreated,
bool incrementUnread, bool incrementUnread,
LastKnownMessageSnapshot? lastKnownMessage) LastKnownMessageSnapshot? lastKnownMessage,
bool createPreviewMessages,
bool incrementGenericMediaUnread)
{ {
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview); var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
if (string.IsNullOrWhiteSpace(preview)) if (string.IsNullOrWhiteSpace(preview))
@@ -471,8 +495,7 @@ public sealed class MaxBridgeSyncService(
chat.UpdatedAt = DateTimeOffset.UtcNow; chat.UpdatedAt = DateTimeOffset.UtcNow;
var echoesKnownOutgoing = IsKnownOutgoingPreviewEcho(lastKnownMessage, preview, previewAt); var echoesKnownOutgoing = IsKnownOutgoingPreviewEcho(lastKnownMessage, preview, previewAt);
var shouldNotifyCandidate = !chatWasCreated && var shouldNotifyCandidate = (chatWasCreated || !string.IsNullOrWhiteSpace(previousPreview)) &&
!string.IsNullOrWhiteSpace(previousPreview) &&
!echoesKnownOutgoing && !echoesKnownOutgoing &&
update.LastMessageIsOutgoing != true && update.LastMessageIsOutgoing != true &&
update.Messages.Count == 0 && update.Messages.Count == 0 &&
@@ -480,12 +503,12 @@ public sealed class MaxBridgeSyncService(
var shouldNotify = shouldNotifyCandidate && var shouldNotify = shouldNotifyCandidate &&
RememberPreviewNotification(chat.Id, preview, previewAt); RememberPreviewNotification(chat.Id, preview, previewAt);
if (incrementUnread && shouldNotify && !isGenericMediaPreview) if (incrementUnread && shouldNotify && (!isGenericMediaPreview || incrementGenericMediaUnread))
{ {
chat.UnreadCount++; chat.UnreadCount++;
} }
var message = shouldNotify && !isGenericMediaPreview var message = shouldNotify && !isGenericMediaPreview && createPreviewMessages
? CreatePreviewMessage(chat, preview, previewAt) ? CreatePreviewMessage(chat, preview, previewAt)
: null; : null;
return new ListPreviewApplyResult(true, message, null, shouldFetchHistory, previewAt); return new ListPreviewApplyResult(true, message, null, shouldFetchHistory, previewAt);
@@ -1298,6 +1321,49 @@ public sealed class MaxBridgeSyncService(
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
} }
private static ChatKind? ResolveChatKind(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return Enum.TryParse<ChatKind>(value.Trim(), ignoreCase: true, out var parsed)
? parsed
: null;
}
private static async Task<Chat?> FindDeletedChatTombstoneAsync(
QMaxDbContext db,
MaxChatUpdate update,
string? nextWebUrl,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(update.Title))
{
return null;
}
var deletedChats = db.Chats
.Where(x => x.DeletedAt != null && x.Title == update.Title);
if (!string.IsNullOrWhiteSpace(nextWebUrl))
{
return await deletedChats.FirstOrDefaultAsync(x => x.WebUrl == nextWebUrl, cancellationToken);
}
if (!string.IsNullOrWhiteSpace(update.AvatarUrl))
{
return await deletedChats.FirstOrDefaultAsync(x => x.AvatarUrl == update.AvatarUrl, cancellationToken);
}
var titleMatches = await deletedChats
.OrderByDescending(x => x.DeletedAt)
.Take(2)
.ToListAsync(cancellationToken);
return titleMatches.Count == 1 ? titleMatches[0] : null;
}
private static string? LastMessagePreview(string? text, IEnumerable<MessageAttachment> attachments) private static string? LastMessagePreview(string? text, IEnumerable<MessageAttachment> attachments)
{ {
var orderedAttachments = attachments.OrderBy(x => x.SortOrder).ToArray(); var orderedAttachments = attachments.OrderBy(x => x.SortOrder).ToArray();
+1 -1
View File
@@ -68,7 +68,7 @@ public sealed class MaxOutboxService(
.Where(chat => IsChatActionDue(chat, now)) .Where(chat => IsChatActionDue(chat, now))
.OrderBy(chat => chat.PendingMaxActionRequestedAt ?? chat.UpdatedAt) .OrderBy(chat => chat.PendingMaxActionRequestedAt ?? chat.UpdatedAt)
.ThenBy(chat => chat.Id) .ThenBy(chat => chat.Id)
.Take(1) .Take(10)
.ToList(); .ToList();
var processed = 0; var processed = 0;
+168 -4
View File
@@ -705,6 +705,42 @@ public sealed class ApiSmokeTests : IDisposable
} }
} }
[Fact]
public async Task BulkDeleteProcessesSeveralPendingChatActionsPerOutboxTick()
{
var bridge = new ResolvingDeleteMaxBridgeClient();
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient>(bridge);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var first = await CreateDirectChatAsync(client, "bulk-delete-one", "Bulk Delete One");
var second = await CreateDirectChatAsync(client, "bulk-delete-two", "Bulk Delete Two");
var third = await CreateDirectChatAsync(client, "bulk-delete-three", "Bulk Delete Three");
var response = await client.PostAsJsonAsync(
"/api/chats/delete",
new ChatBulkActionRequest([first.Id, second.Id, third.Id]),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, response);
Assert.True(await ProcessOutboxAsync(factory) >= 3);
Assert.Equal(3, bridge.DeleteChatUrls.Count);
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var pendingCount = await db.Chats.CountAsync(x =>
(x.Id == first.Id || x.Id == second.Id || x.Id == third.Id) &&
x.PendingMaxAction != null);
Assert.Equal(0, pendingCount);
}
[Fact] [Fact]
public async Task GetMessagesReturnsSqlCacheWithoutInlineMaxHistorySyncByDefault() public async Task GetMessagesReturnsSqlCacheWithoutInlineMaxHistorySyncByDefault()
{ {
@@ -870,6 +906,66 @@ public sealed class ApiSmokeTests : IDisposable
} }
} }
[Fact]
public async Task SyncCreatesNewChatAndPreviewMessageForFreshIncomingListUpdate()
{
var pushNotifications = new RecordingPushNotificationService();
var marker = Guid.NewGuid().ToString("N");
var externalId = $"fresh-preview-chat-{marker}";
var title = "Fresh Preview Chat";
var previewText = $"fresh incoming {marker}";
var now = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient
{
Updates =
[
new MaxChatUpdate(
externalId,
title,
null,
now,
[],
previewText,
false,
now,
now.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"),
$"https://max.test/chat/{externalId}")
]
};
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.RemoveAll<IPushNotificationService>();
services.AddSingleton<IMaxBridgeClient>(bridge);
services.AddSingleton<IPushNotificationService>(pushNotifications);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var chat = await db.Chats.SingleAsync(x => x.ExternalId == externalId);
Assert.Equal(title, chat.Title);
Assert.Equal(previewText, chat.LastMessagePreview);
Assert.Equal(1, chat.UnreadCount);
var message = await db.Messages.SingleAsync(x => x.ChatId == chat.Id);
Assert.Equal(previewText, message.Text);
Assert.Equal(MessageDirection.Incoming, message.Direction);
Assert.StartsWith("preview:", message.ExternalId);
var previewPush = Assert.Single(pushNotifications.SentMessages);
Assert.Equal(chat.Id, previewPush.Chat.Id);
Assert.Equal(previewText, previewPush.Message.Text);
}
[Fact] [Fact]
public async Task SyncDoesNotCreateTextOnlyMessageForGenericMediaPreviewOnlyIncomingUpdate() public async Task SyncDoesNotCreateTextOnlyMessageForGenericMediaPreviewOnlyIncomingUpdate()
{ {
@@ -929,10 +1025,7 @@ public sealed class ApiSmokeTests : IDisposable
x.ChatId == chat.Id && x.ChatId == chat.Id &&
x.ExternalId != null && x.ExternalId != null &&
x.ExternalId.StartsWith("preview:"))); x.ExternalId.StartsWith("preview:")));
var previewPush = Assert.Single(pushNotifications.SentMessages); Assert.Empty(pushNotifications.SentMessages);
Assert.Equal(chat.Id, previewPush.Chat.Id);
Assert.Equal("\u041c\u0435\u0434\u0438\u0430", previewPush.Message.Text);
Assert.Equal(MessageDirection.Incoming, previewPush.Message.Direction);
} }
[Fact] [Fact]
@@ -1181,6 +1274,77 @@ public sealed class ApiSmokeTests : IDisposable
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
[Fact]
public async Task SyncDoesNotRecreateDeletedChatWhenExternalIdChangesButWebUrlMatches()
{
const string oldExternalId = "deleted-sync-old-external";
const string newExternalId = "deleted-sync-new-external";
const string title = "Deleted Tombstone";
const string chatUrl = "https://max.test/chat/deleted-tombstone";
var sentAt = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient
{
Updates =
[
new MaxChatUpdate(
newExternalId,
title,
null,
sentAt,
[],
"remote hello after external id change",
false,
sentAt,
sentAt.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"),
chatUrl)
]
};
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient>(bridge);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, oldExternalId, title);
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
stored.WebUrl = chatUrl;
await db.SaveChangesAsync();
}
var deleteResponse = await client.PostAsJsonAsync(
"/api/chats/delete",
new ChatBulkActionRequest([chat.Id]),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse);
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.DoesNotContain(chats!, x => x.Title == title);
await using var verifyScope = factory.Services.CreateAsyncScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChats = await verifyDb.Chats.Where(x => x.Title == title).ToListAsync();
var tombstone = Assert.Single(storedChats);
Assert.Equal(oldExternalId, tombstone.ExternalId);
Assert.NotNull(tombstone.DeletedAt);
tombstone.PendingMaxAction = null;
tombstone.PendingMaxActionRequestedAt = null;
tombstone.PendingMaxActionLastAttemptAt = null;
tombstone.PendingMaxActionError = null;
await verifyDb.SaveChangesAsync();
}
[Fact] [Fact]
public async Task FailedMaxTextSendMarksLocalMessageFailed() public async Task FailedMaxTextSendMarksLocalMessageFailed()
{ {
+817 -18
View File
File diff suppressed because it is too large Load Diff