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 {