Improve Android chat navigation and sharing

This commit is contained in:
Курнат Андрей
2026-07-17 17:04:28 +03:00
parent 51437c81a5
commit 27f9743c71
3 changed files with 432 additions and 17 deletions
+10
View File
@@ -50,6 +50,16 @@
<action android:name="QMAX_OPEN_CHAT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -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<Uri>
)
class MainActivity : ComponentActivity() {
private val pendingOpenChatId = mutableStateOf<String?>(null)
private val pendingIncomingShare = mutableStateOf<IncomingShare?>(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<Uri>(Intent.EXTRA_STREAM))?.let(::add)
shareIntent.getParcelableArrayListExtra<Uri>(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<PhoneContactDto> {
val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
@@ -311,16 +368,22 @@ private fun readPhoneContacts(contentResolver: ContentResolver): List<PhoneConta
private fun QMaxApp(
vm: QMaxViewModel,
appearance: AppearanceSettings,
incomingShare: IncomingShare?,
onIncomingShareConsumed: () -> 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<SettingsSection?>(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<String?>(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<ChatDto>,
session: QMaxSession?,
cachedAvatarPaths: Map<String, String>,
sending: Boolean,
onCacheAvatar: (String) -> Unit,
onSend: (List<ChatDto>) -> Unit,
onClose: () -> Unit
) {
val sortedChats = remember(chats) {
chats.sortedWith(
compareByDescending<ChatDto> { 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<String>()) }
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<DetectedMessageLink> {
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<MessageDto>,
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<MessageDto>): List<TimelineItem> {
val result = mutableListOf<TimelineItem>()
var previousDay: String? = null
@@ -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<MessageDto> = emptyList(),
val chatSearchResults: List<MessageDto> = 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<ChatDto>,
text: String?,
attachmentUris: List<Uri>,
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 {