From 27f9743c71e7b1fdc53bf392fd9c16d01c734fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Fri, 17 Jul 2026 17:04:28 +0300 Subject: [PATCH] Improve Android chat navigation and sharing --- android/app/src/main/AndroidManifest.xml | 10 + .../main/java/xyz/kusoft/qmax/MainActivity.kt | 379 +++++++++++++++++- .../java/xyz/kusoft/qmax/ui/QMaxViewModel.kt | 60 +++ 3 files changed, 432 insertions(+), 17 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d723d01..8d7b180 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -50,6 +50,16 @@ + + + + + + + + + + diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index 073ddf8..933d820 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -12,6 +12,9 @@ import android.os.Build import android.os.Bundle import android.provider.OpenableColumns import android.provider.ContactsContract +import android.text.SpannableString +import android.text.style.URLSpan +import android.text.util.Linkify import android.view.WindowManager import android.widget.Toast import androidx.activity.compose.BackHandler @@ -148,11 +151,18 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.sp @@ -224,14 +234,21 @@ import xyz.kusoft.qmax.ui.theme.QMaxUnread private const val MaxPendingAttachments = 10 private val QuickReactions = listOf("\u2764\ufe0f", "\ud83d\udc4d", "\ud83d\ude02", "\ud83d\ude2e", "\ud83d\ude22", "\ud83d\ude21") +private data class IncomingShare( + val text: String?, + val uris: List +) + class MainActivity : ComponentActivity() { private val pendingOpenChatId = mutableStateOf(null) + private val pendingIncomingShare = mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) pendingOpenChatId.value = intent.chatIdExtra() + pendingIncomingShare.value = intent.incomingShare() val container = (application as QMaxApplication).container setContent { val appearance by container.appearanceStore.settings.collectAsStateWithLifecycle( @@ -257,6 +274,8 @@ class MainActivity : ComponentActivity() { QMaxApp( vm = vm, appearance = appearance, + incomingShare = pendingIncomingShare.value, + onIncomingShareConsumed = { pendingIncomingShare.value = null }, onAppearanceChange = { updated -> appearanceScope.launch { container.appearanceStore.save(updated) } } @@ -269,11 +288,49 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) pendingOpenChatId.value = intent.chatIdExtra() + intent.incomingShare()?.let { pendingIncomingShare.value = it } } } private fun Intent?.chatIdExtra(): String? = this?.getStringExtra("chatId")?.takeIf { it.isNotBlank() } +@Suppress("DEPRECATION") +private fun Intent?.incomingShare(): IncomingShare? { + val shareIntent = this ?: return null + if (shareIntent.action != Intent.ACTION_SEND && shareIntent.action != Intent.ACTION_SEND_MULTIPLE) return null + + val sharedData = shareIntent.data + val subject = shareIntent.getCharSequenceExtra(Intent.EXTRA_SUBJECT)?.toString()?.trim().orEmpty() + val body = shareIntent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()?.trim().orEmpty() + val dataText = sharedData + ?.takeIf { it.scheme.equals("http", ignoreCase = true) || it.scheme.equals("https", ignoreCase = true) } + ?.toString() + .orEmpty() + val text = listOf(subject, body, dataText) + .filter { it.isNotBlank() } + .distinct() + .joinToString("\n") + .takeIf { it.isNotBlank() } + + val uris = buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + shareIntent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)?.let(::add) + shareIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.let(::addAll) + } else { + (shareIntent.getParcelableExtra(Intent.EXTRA_STREAM))?.let(::add) + shareIntent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let(::addAll) + } + shareIntent.clipData?.let { clip -> + repeat(clip.itemCount) { index -> clip.getItemAt(index).uri?.let(::add) } + } + sharedData + ?.takeIf { it.scheme.equals("content", ignoreCase = true) || it.scheme.equals("file", ignoreCase = true) } + ?.let(::add) + }.distinctBy(Uri::toString).take(MaxPendingAttachments) + + return if (text == null && uris.isEmpty()) null else IncomingShare(text = text, uris = uris) +} + private fun readPhoneContacts(contentResolver: ContentResolver): List { val projection = arrayOf( ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, @@ -311,16 +368,22 @@ private fun readPhoneContacts(contentResolver: ContentResolver): List Unit, onAppearanceChange: (AppearanceSettings) -> Unit ) { val state by vm.state val context = LocalContext.current + var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) } var notificationPermissionRequested by rememberSaveable { mutableStateOf(false) } val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { notificationPermissionRequested = true } LaunchedEffect(state.session?.serverUrl, state.session?.userName) { + if (state.session == null) { + activeTab = MainTab.Chats + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && state.session != null && !notificationPermissionRequested && @@ -334,7 +397,13 @@ private fun QMaxApp( when { state.session == null -> LoginScreen(vm) state.selectedChat != null -> ChatScreen(vm) - else -> ChatListScreen(vm, appearance, onAppearanceChange) + else -> ChatListScreen( + vm = vm, + appearance = appearance, + activeTab = activeTab, + onActiveTabChange = { activeTab = it }, + onAppearanceChange = onAppearanceChange + ) } state.previewImageUrl?.let { url -> @@ -364,6 +433,26 @@ private fun QMaxApp( onClose = vm::closeImage ) } + + if (incomingShare != null && state.session != null) { + IncomingShareDialog( + share = incomingShare, + chats = state.chats, + session = state.session, + cachedAvatarPaths = state.cachedAvatarPaths, + sending = state.sendingMessage, + onCacheAvatar = vm::cacheAvatar, + onSend = { chats -> + vm.sendSharedContent( + targetChats = chats, + text = incomingShare.text, + attachmentUris = incomingShare.uris, + onSuccess = onIncomingShareConsumed + ) + }, + onClose = onIncomingShareConsumed + ) + } } } @@ -397,6 +486,7 @@ private fun AutoLoginScreen(loading: Boolean, error: String?, onRetry: () -> Uni } } } + } } @@ -491,10 +581,11 @@ private enum class SettingsSection { private fun ChatListScreen( vm: QMaxViewModel, appearance: AppearanceSettings, + activeTab: MainTab, + onActiveTabChange: (MainTab) -> Unit, onAppearanceChange: (AppearanceSettings) -> Unit ) { val state by vm.state - var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) } var settingsSection by rememberSaveable { mutableStateOf(null) } var menuOpen by remember { mutableStateOf(false) } var selectionMenuOpen by remember { mutableStateOf(false) } @@ -594,7 +685,7 @@ private fun ChatListScreen( onSearch = vm::updateSearchQuery, onSubscribeChannel = vm::subscribeMaxChannel, onTabSelected = { tab -> - activeTab = tab + onActiveTabChange(tab) settingsSection = null vm.updateSearchQuery("") vm.clearChatSelection() @@ -605,7 +696,7 @@ private fun ChatListScreen( onAddContact = vm::addContact, onRemoveContact = { pendingContactRemoval = it }, onNewChat = { - activeTab = MainTab.Contacts + onActiveTabChange(MainTab.Contacts) vm.updateSearchQuery("") }, onCacheAvatar = vm::cacheAvatar, @@ -3512,6 +3603,14 @@ private fun ChatScreen(vm: QMaxViewModel) { } } val timeline = remember(visibleMessages) { buildTimeline(visibleMessages) } + val openingUnreadCount = state.initialUnreadCount + val firstUnreadMessageId = remember(visibleMessages, openingUnreadCount, state.initialUnreadThrough) { + findFirstUnreadMessageId( + messages = visibleMessages, + unreadCount = openingUnreadCount, + unreadThrough = state.initialUnreadThrough + ) + } val latestMessage = remember(visibleMessages) { visibleMessages.maxWithOrNull(messageChronologyComparator) } @@ -3522,6 +3621,7 @@ private fun ChatScreen(vm: QMaxViewModel) { message.id to index }.toMap() } + val firstUnreadTimelineIndex = firstUnreadMessageId?.let(timelineMessageIndexById::get) val imagePreviewSources = remember(visibleMessages, state.cachedImagePaths, session.serverUrl) { visibleMessages.flatMap { message -> message.attachments @@ -3572,6 +3672,7 @@ private fun ChatScreen(vm: QMaxViewModel) { } var previousTimelineSize by remember(chat.id) { mutableStateOf(0) } var previousLatestMessageId by remember(chat.id) { mutableStateOf(null) } + var initialScrollCompleted by remember(chat.id) { mutableStateOf(false) } val showScrollToBottom by remember { derivedStateOf { val layout = listState.layoutInfo @@ -3588,7 +3689,8 @@ private fun ChatScreen(vm: QMaxViewModel) { LaunchedEffect(latestMessage?.id) { val previousId = previousLatestMessageId previousLatestMessageId = latestMessage?.id - if (latestMessage != null && + if (initialScrollCompleted && + latestMessage != null && previousId != null && previousId != latestMessage.id && latestMessage.direction.equals("Incoming", ignoreCase = true) && @@ -3619,11 +3721,29 @@ private fun ChatScreen(vm: QMaxViewModel) { } } - LaunchedEffect(timeline.size) { - if (timeline.isNotEmpty()) { - if (previousTimelineSize == 0) { - listState.scrollToItem(timeline.lastIndex) - } else if (!showScrollToBottom) { + LaunchedEffect( + timeline.size, + state.initialMessageLoadComplete, + firstUnreadTimelineIndex, + chatSearchMode + ) { + if (timeline.isEmpty() || chatSearchMode) return@LaunchedEffect + + if (!initialScrollCompleted) { + if (!state.initialMessageLoadComplete) return@LaunchedEffect + val targetIndex = if (openingUnreadCount > 0) { + firstUnreadTimelineIndex ?: timeline.lastIndex + } else { + timeline.lastIndex + } + listState.scrollToItem(targetIndex) + initialScrollCompleted = true + previousTimelineSize = timeline.size + return@LaunchedEffect + } + + if (timeline.size != previousTimelineSize) { + if (openingUnreadCount == 0 && !showScrollToBottom) { listState.animateScrollToItem(timeline.lastIndex) } previousTimelineSize = timeline.size @@ -3956,6 +4076,132 @@ private fun ChatScreen(vm: QMaxViewModel) { } } +@Composable +private fun IncomingShareDialog( + share: IncomingShare, + chats: List, + session: QMaxSession?, + cachedAvatarPaths: Map, + sending: Boolean, + onCacheAvatar: (String) -> Unit, + onSend: (List) -> Unit, + onClose: () -> Unit +) { + val sortedChats = remember(chats) { + chats.sortedWith( + compareByDescending { it.isPinned } + .thenByDescending { it.lastMessageAt.orEmpty() } + ) + } + val shareKey = remember(share) { "${share.text}|${share.uris.joinToString()}" } + var query by rememberSaveable(shareKey) { mutableStateOf("") } + var selectedChatIds by remember(shareKey) { mutableStateOf(emptySet()) } + val filteredChats = remember(sortedChats, query) { + val term = query.trim() + if (term.isBlank()) { + sortedChats + } else { + sortedChats.filter { chat -> + chat.title.contains(term, ignoreCase = true) || + chat.lastMessagePreview?.contains(term, ignoreCase = true) == true + } + } + } + val selectedChats = remember(sortedChats, selectedChatIds) { + sortedChats.filter { it.id in selectedChatIds } + } + + AlertDialog( + onDismissRequest = { if (!sending) onClose() }, + confirmButton = { + Button( + onClick = { onSend(selectedChats) }, + enabled = selectedChats.isNotEmpty() && !sending + ) { + if (sending) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = QMaxOnAccent + ) + } else { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) + } + Spacer(Modifier.width(6.dp)) + Text(if (selectedChats.size <= 1) "Отправить" else "Отправить (${selectedChats.size})") + } + }, + dismissButton = { + TextButton(onClick = onClose, enabled = !sending) { + Text("Отмена") + } + }, + title = { Text("Поделиться в QMAX") }, + text = { + Column(Modifier.fillMaxWidth()) { + Surface( + color = QMaxSurfaceVariant, + shape = RoundedCornerShape(14.dp), + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.padding(horizontal = 14.dp, vertical = 11.dp)) { + share.text?.let { text -> + Text( + text, + color = QMaxText, + maxLines = 4, + overflow = TextOverflow.Ellipsis + ) + } + if (share.uris.isNotEmpty()) { + if (share.text != null) Spacer(Modifier.height(4.dp)) + Text( + "Вложений: ${share.uris.size}", + color = QMaxBlue, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + Spacer(Modifier.height(10.dp)) + ForwardSearchField(query = query, onQuery = { query = it }, placeholder = "Поиск чатов") + Spacer(Modifier.height(8.dp)) + when { + sortedChats.isEmpty() -> EmptyState("Чатов пока нет", modifier = Modifier.height(220.dp)) + filteredChats.isEmpty() -> EmptyState("Ничего не найдено", modifier = Modifier.height(220.dp)) + else -> LazyColumn(Modifier.fillMaxWidth().height(430.dp)) { + items(filteredChats, key = { it.id }) { chat -> + val selected = chat.id in selectedChatIds + ForwardChatRow( + chat = chat, + isCurrent = false, + selected = selected, + session = session, + cachedAvatarPaths = cachedAvatarPaths, + onCacheAvatar = onCacheAvatar, + onClick = { + if (!sending) { + selectedChatIds = if (selected) { + selectedChatIds - chat.id + } else { + selectedChatIds + chat.id + } + } + } + ) + HorizontalDivider( + color = QMaxDivider, + thickness = 0.6.dp, + modifier = Modifier.padding(start = 56.dp) + ) + } + } + } + } + } + ) +} + @Composable private fun ForwardMessageDialog( message: MessageDto, @@ -4145,7 +4391,11 @@ private fun ForwardMessagePreview(message: MessageDto) { } @Composable -private fun ForwardSearchField(query: String, onQuery: (String) -> Unit) { +private fun ForwardSearchField( + query: String, + onQuery: (String) -> Unit, + placeholder: String = "Кому переслать" +) { Surface(color = QMaxSurfaceVariant, shape = RoundedCornerShape(24.dp)) { Row( modifier = Modifier.fillMaxWidth().height(46.dp).padding(start = 14.dp, end = 4.dp), @@ -4155,7 +4405,7 @@ private fun ForwardSearchField(query: String, onQuery: (String) -> Unit) { TextField( value = query, onValueChange = onQuery, - placeholder = { Text("Кому переслать", color = QMaxMuted) }, + placeholder = { Text(placeholder, color = QMaxMuted) }, singleLine = true, colors = TextFieldDefaults.colors( focusedTextColor = QMaxText, @@ -4409,6 +4659,77 @@ private fun DaySeparator(label: String) { } } +private data class DetectedMessageLink( + val start: Int, + val end: Int, + val target: String +) + +private fun detectMessageLinks(text: String): List { + val spannable = SpannableString(text) + Linkify.addLinks( + spannable, + Linkify.WEB_URLS or Linkify.EMAIL_ADDRESSES or Linkify.PHONE_NUMBERS + ) + return spannable + .getSpans(0, spannable.length, URLSpan::class.java) + .mapNotNull { span -> + val start = spannable.getSpanStart(span) + val end = spannable.getSpanEnd(span) + DetectedMessageLink(start, end, span.url) + .takeIf { start >= 0 && end > start && end <= text.length } + } + .sortedBy(DetectedMessageLink::start) +} + +@Composable +private fun LinkifiedMessageText( + text: String, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + val linkColor = QMaxBlue + val links = remember(text) { detectMessageLinks(text) } + val annotatedText = remember(text, links, linkColor, uriHandler, context) { + buildAnnotatedString { + var cursor = 0 + links.forEach { link -> + if (link.start < cursor) return@forEach + append(text.substring(cursor, link.start)) + withLink( + LinkAnnotation.Url( + url = link.target, + styles = TextLinkStyles( + style = SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline + ) + ) + ) { annotation -> + val target = (annotation as LinkAnnotation.Url).url + try { + uriHandler.openUri(target) + } catch (_: Exception) { + Toast.makeText(context, "Нет приложения для открытия ссылки", Toast.LENGTH_SHORT).show() + } + } + ) { + append(text.substring(link.start, link.end)) + } + cursor = link.end + } + append(text.substring(cursor)) + } + } + Text( + text = annotatedText, + color = QMaxMessageText, + style = QMaxChatTextStyle, + modifier = modifier + ) +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun MessageRow( @@ -4521,11 +4842,9 @@ private fun MessageRow( ) } } - message.text?.takeIf { it.isNotBlank() }?.let { - Text( - text = it, - color = QMaxMessageText, - style = QMaxChatTextStyle, + message.text?.takeIf { it.isNotBlank() }?.let { text -> + LinkifiedMessageText( + text = text, modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) ) } @@ -6031,6 +6350,32 @@ private sealed interface TimelineItem { } } +private fun findFirstUnreadMessageId( + messages: List, + unreadCount: Int, + unreadThrough: String? +): String? { + if (unreadCount <= 0 || messages.isEmpty()) return null + + val orderedMessages = messages.sortedWith(messageChronologyComparator) + val snapshotMessages = unreadThrough?.let { cutoff -> + val cutoffMillis = messageSortMillis(cutoff) + orderedMessages.filter { message -> + val messageMillis = messageSortMillis(message.sentAt) + if (cutoffMillis != Long.MIN_VALUE && messageMillis != Long.MIN_VALUE) { + messageMillis <= cutoffMillis + } else { + message.sentAt <= cutoff + } + } + }.orEmpty().ifEmpty { orderedMessages } + val incomingMessages = snapshotMessages.filter { + it.direction.equals("Incoming", ignoreCase = true) + } + val unreadCandidates = incomingMessages.ifEmpty { snapshotMessages } + return unreadCandidates.takeLast(unreadCount).firstOrNull()?.id +} + private fun buildTimeline(messages: List): List { val result = mutableListOf() var previousDay: String? = null diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt index aa3dc3e..2b16bf0 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt @@ -41,6 +41,9 @@ data class QMaxUiState( val contactsLoading: Boolean = false, val contactsError: String? = null, val selectedChat: ChatDto? = null, + val initialUnreadCount: Int = 0, + val initialUnreadThrough: String? = null, + val initialMessageLoadComplete: Boolean = false, val messages: List = emptyList(), val chatSearchResults: List = emptyList(), val chatSearchLoading: Boolean = false, @@ -509,6 +512,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { ) state.value = state.value.copy( selectedChat = openedChat, + initialUnreadCount = chat.unreadCount.coerceAtLeast(0), + initialUnreadThrough = chat.lastMessageAt, + initialMessageLoadComplete = false, chats = updatedChats, messages = emptyList(), chatSearchResults = emptyList(), @@ -583,6 +589,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { } state.value = state.value.copy( selectedChat = null, + initialUnreadCount = 0, + initialUnreadThrough = null, + initialMessageLoadComplete = false, chats = reorderedChats, messages = emptyList(), chatSearchResults = emptyList(), @@ -655,6 +664,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { if (error.code == 404 && recoverMissingChat(session, chat)) { return@launchLoading } + markInitialMessageLoadComplete(chat.id) + throw error + } catch (error: Throwable) { + markInitialMessageLoadComplete(chat.id) throw error } if (state.value.selectedChat?.id == chat.id) { @@ -662,6 +675,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { selected?.let(::rememberChatRead) state.value = state.value.copy( selectedChat = selected, + initialMessageLoadComplete = true, chats = normalizeChats(state.value.chats, selectedChatId = chat.id), messages = fresh ) @@ -716,6 +730,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { if (replacement == null) { state.value = state.value.copy( chats = orderedFresh, + initialMessageLoadComplete = true, error = null ) repository.cacheChats(session, orderedFresh) @@ -732,6 +747,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { ) state.value = state.value.copy( selectedChat = openedChat, + initialUnreadCount = maxOf(state.value.initialUnreadCount, replacement.unreadCount), + initialUnreadThrough = replacement.lastMessageAt ?: state.value.initialUnreadThrough, + initialMessageLoadComplete = false, chats = chats, messages = emptyList(), chatPresenceText = null, @@ -747,6 +765,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { if (state.value.selectedChat?.id == openedChat.id) { state.value = state.value.copy( selectedChat = state.value.selectedChat?.copy(unreadCount = 0), + initialMessageLoadComplete = true, chats = normalizeChats(state.value.chats, selectedChatId = openedChat.id), messages = freshMessages ) @@ -850,6 +869,41 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { } } + fun sendSharedContent( + targetChats: List, + text: String?, + attachmentUris: List, + onSuccess: () -> Unit = {} + ) = launchLoading(false) { + if (targetChats.isEmpty()) return@launchLoading + val normalizedText = text?.trim().orEmpty() + if (normalizedText.isBlank() && attachmentUris.isEmpty()) return@launchLoading + + val session = requireSession() + state.value = state.value.copy(sendingMessage = true, error = null) + try { + targetChats.forEach { chat -> + if (attachmentUris.isEmpty()) { + repository.sendMessage(session, chat.id, normalizedText, replyToMessageId = null) + } else { + attachmentUris.forEachIndexed { index, uri -> + repository.upload( + session = session, + chatId = chat.id, + uri = uri, + caption = normalizedText.takeIf { index == 0 && it.isNotBlank() }, + replyToMessageId = null + ) + } + } + } + onSuccess() + loadChats() + } finally { + state.value = state.value.copy(sendingMessage = false) + } + } + fun upload(uri: Uri) = launchLoading(false) { val session = requireSession() val chat = state.value.selectedChat ?: return@launchLoading @@ -1441,6 +1495,12 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { } } + private fun markInitialMessageLoadComplete(chatId: String) { + if (state.value.selectedChat?.id == chatId && !state.value.initialMessageLoadComplete) { + state.value = state.value.copy(initialMessageLoadComplete = true) + } + } + private fun syncChatRead(chatId: String) { val session = state.value.session ?: return viewModelScope.launch {