Fix async chat actions and channel sync
This commit is contained in:
@@ -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,19 +703,24 @@ private fun TelegramChatListContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatListFloatingActions(
|
if (activeTab == MainTab.Chats) {
|
||||||
modifier = Modifier
|
ChatListFloatingActions(
|
||||||
.align(Alignment.BottomEnd)
|
modifier = Modifier
|
||||||
.navigationBarsPadding()
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 22.dp, bottom = 96.dp),
|
.navigationBarsPadding()
|
||||||
onNewChat = onNewChat
|
.padding(end = 22.dp, bottom = 96.dp),
|
||||||
)
|
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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
+827
-28
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user