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" /> <action android:name="QMAX_OPEN_CHAT" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
</intent-filter> </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> </activity>
</application> </application>
</manifest> </manifest>
@@ -12,6 +12,9 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.provider.ContactsContract import android.provider.ContactsContract
import android.text.SpannableString
import android.text.style.URLSpan
import android.text.util.Linkify
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler 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.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.AnnotatedString 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -224,14 +234,21 @@ import xyz.kusoft.qmax.ui.theme.QMaxUnread
private const val MaxPendingAttachments = 10 private const val MaxPendingAttachments = 10
private val QuickReactions = listOf("\u2764\ufe0f", "\ud83d\udc4d", "\ud83d\ude02", "\ud83d\ude2e", "\ud83d\ude22", "\ud83d\ude21") 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() { class MainActivity : ComponentActivity() {
private val pendingOpenChatId = mutableStateOf<String?>(null) private val pendingOpenChatId = mutableStateOf<String?>(null)
private val pendingIncomingShare = mutableStateOf<IncomingShare?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
pendingOpenChatId.value = intent.chatIdExtra() pendingOpenChatId.value = intent.chatIdExtra()
pendingIncomingShare.value = intent.incomingShare()
val container = (application as QMaxApplication).container val container = (application as QMaxApplication).container
setContent { setContent {
val appearance by container.appearanceStore.settings.collectAsStateWithLifecycle( val appearance by container.appearanceStore.settings.collectAsStateWithLifecycle(
@@ -257,6 +274,8 @@ class MainActivity : ComponentActivity() {
QMaxApp( QMaxApp(
vm = vm, vm = vm,
appearance = appearance, appearance = appearance,
incomingShare = pendingIncomingShare.value,
onIncomingShareConsumed = { pendingIncomingShare.value = null },
onAppearanceChange = { updated -> onAppearanceChange = { updated ->
appearanceScope.launch { container.appearanceStore.save(updated) } appearanceScope.launch { container.appearanceStore.save(updated) }
} }
@@ -269,11 +288,49 @@ class MainActivity : ComponentActivity() {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
pendingOpenChatId.value = intent.chatIdExtra() pendingOpenChatId.value = intent.chatIdExtra()
intent.incomingShare()?.let { pendingIncomingShare.value = it }
} }
} }
private fun Intent?.chatIdExtra(): String? = this?.getStringExtra("chatId")?.takeIf { it.isNotBlank() } 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> { private fun readPhoneContacts(contentResolver: ContentResolver): List<PhoneContactDto> {
val projection = arrayOf( val projection = arrayOf(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
@@ -311,16 +368,22 @@ private fun readPhoneContacts(contentResolver: ContentResolver): List<PhoneConta
private fun QMaxApp( private fun QMaxApp(
vm: QMaxViewModel, vm: QMaxViewModel,
appearance: AppearanceSettings, appearance: AppearanceSettings,
incomingShare: IncomingShare?,
onIncomingShareConsumed: () -> Unit,
onAppearanceChange: (AppearanceSettings) -> Unit onAppearanceChange: (AppearanceSettings) -> Unit
) { ) {
val state by vm.state val state by vm.state
val context = LocalContext.current val context = LocalContext.current
var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) }
var notificationPermissionRequested by rememberSaveable { mutableStateOf(false) } var notificationPermissionRequested by rememberSaveable { mutableStateOf(false) }
val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
notificationPermissionRequested = true notificationPermissionRequested = true
} }
LaunchedEffect(state.session?.serverUrl, state.session?.userName) { LaunchedEffect(state.session?.serverUrl, state.session?.userName) {
if (state.session == null) {
activeTab = MainTab.Chats
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
state.session != null && state.session != null &&
!notificationPermissionRequested && !notificationPermissionRequested &&
@@ -334,7 +397,13 @@ private fun QMaxApp(
when { when {
state.session == null -> LoginScreen(vm) state.session == null -> LoginScreen(vm)
state.selectedChat != null -> ChatScreen(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 -> state.previewImageUrl?.let { url ->
@@ -364,6 +433,26 @@ private fun QMaxApp(
onClose = vm::closeImage 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( private fun ChatListScreen(
vm: QMaxViewModel, vm: QMaxViewModel,
appearance: AppearanceSettings, appearance: AppearanceSettings,
activeTab: MainTab,
onActiveTabChange: (MainTab) -> Unit,
onAppearanceChange: (AppearanceSettings) -> Unit onAppearanceChange: (AppearanceSettings) -> Unit
) { ) {
val state by vm.state val state by vm.state
var activeTab by rememberSaveable { mutableStateOf(MainTab.Chats) }
var settingsSection by rememberSaveable { mutableStateOf<SettingsSection?>(null) } var settingsSection by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
var menuOpen by remember { mutableStateOf(false) } var menuOpen by remember { mutableStateOf(false) }
var selectionMenuOpen by remember { mutableStateOf(false) } var selectionMenuOpen by remember { mutableStateOf(false) }
@@ -594,7 +685,7 @@ private fun ChatListScreen(
onSearch = vm::updateSearchQuery, onSearch = vm::updateSearchQuery,
onSubscribeChannel = vm::subscribeMaxChannel, onSubscribeChannel = vm::subscribeMaxChannel,
onTabSelected = { tab -> onTabSelected = { tab ->
activeTab = tab onActiveTabChange(tab)
settingsSection = null settingsSection = null
vm.updateSearchQuery("") vm.updateSearchQuery("")
vm.clearChatSelection() vm.clearChatSelection()
@@ -605,7 +696,7 @@ private fun ChatListScreen(
onAddContact = vm::addContact, onAddContact = vm::addContact,
onRemoveContact = { pendingContactRemoval = it }, onRemoveContact = { pendingContactRemoval = it },
onNewChat = { onNewChat = {
activeTab = MainTab.Contacts onActiveTabChange(MainTab.Contacts)
vm.updateSearchQuery("") vm.updateSearchQuery("")
}, },
onCacheAvatar = vm::cacheAvatar, onCacheAvatar = vm::cacheAvatar,
@@ -3512,6 +3603,14 @@ private fun ChatScreen(vm: QMaxViewModel) {
} }
} }
val timeline = remember(visibleMessages) { buildTimeline(visibleMessages) } 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) { val latestMessage = remember(visibleMessages) {
visibleMessages.maxWithOrNull(messageChronologyComparator) visibleMessages.maxWithOrNull(messageChronologyComparator)
} }
@@ -3522,6 +3621,7 @@ private fun ChatScreen(vm: QMaxViewModel) {
message.id to index message.id to index
}.toMap() }.toMap()
} }
val firstUnreadTimelineIndex = firstUnreadMessageId?.let(timelineMessageIndexById::get)
val imagePreviewSources = remember(visibleMessages, state.cachedImagePaths, session.serverUrl) { val imagePreviewSources = remember(visibleMessages, state.cachedImagePaths, session.serverUrl) {
visibleMessages.flatMap { message -> visibleMessages.flatMap { message ->
message.attachments message.attachments
@@ -3572,6 +3672,7 @@ private fun ChatScreen(vm: QMaxViewModel) {
} }
var previousTimelineSize by remember(chat.id) { mutableStateOf(0) } var previousTimelineSize by remember(chat.id) { mutableStateOf(0) }
var previousLatestMessageId by remember(chat.id) { mutableStateOf<String?>(null) } var previousLatestMessageId by remember(chat.id) { mutableStateOf<String?>(null) }
var initialScrollCompleted by remember(chat.id) { mutableStateOf(false) }
val showScrollToBottom by remember { val showScrollToBottom by remember {
derivedStateOf { derivedStateOf {
val layout = listState.layoutInfo val layout = listState.layoutInfo
@@ -3588,7 +3689,8 @@ private fun ChatScreen(vm: QMaxViewModel) {
LaunchedEffect(latestMessage?.id) { LaunchedEffect(latestMessage?.id) {
val previousId = previousLatestMessageId val previousId = previousLatestMessageId
previousLatestMessageId = latestMessage?.id previousLatestMessageId = latestMessage?.id
if (latestMessage != null && if (initialScrollCompleted &&
latestMessage != null &&
previousId != null && previousId != null &&
previousId != latestMessage.id && previousId != latestMessage.id &&
latestMessage.direction.equals("Incoming", ignoreCase = true) && latestMessage.direction.equals("Incoming", ignoreCase = true) &&
@@ -3619,11 +3721,29 @@ private fun ChatScreen(vm: QMaxViewModel) {
} }
} }
LaunchedEffect(timeline.size) { LaunchedEffect(
if (timeline.isNotEmpty()) { timeline.size,
if (previousTimelineSize == 0) { state.initialMessageLoadComplete,
listState.scrollToItem(timeline.lastIndex) firstUnreadTimelineIndex,
} else if (!showScrollToBottom) { 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) listState.animateScrollToItem(timeline.lastIndex)
} }
previousTimelineSize = timeline.size 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 @Composable
private fun ForwardMessageDialog( private fun ForwardMessageDialog(
message: MessageDto, message: MessageDto,
@@ -4145,7 +4391,11 @@ private fun ForwardMessagePreview(message: MessageDto) {
} }
@Composable @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)) { Surface(color = QMaxSurfaceVariant, shape = RoundedCornerShape(24.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth().height(46.dp).padding(start = 14.dp, end = 4.dp), 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( TextField(
value = query, value = query,
onValueChange = onQuery, onValueChange = onQuery,
placeholder = { Text("Кому переслать", color = QMaxMuted) }, placeholder = { Text(placeholder, color = QMaxMuted) },
singleLine = true, singleLine = true,
colors = TextFieldDefaults.colors( colors = TextFieldDefaults.colors(
focusedTextColor = QMaxText, 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
private fun MessageRow( private fun MessageRow(
@@ -4521,11 +4842,9 @@ private fun MessageRow(
) )
} }
} }
message.text?.takeIf { it.isNotBlank() }?.let { message.text?.takeIf { it.isNotBlank() }?.let { text ->
Text( LinkifiedMessageText(
text = it, text = text,
color = QMaxMessageText,
style = QMaxChatTextStyle,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) 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> { private fun buildTimeline(messages: List<MessageDto>): List<TimelineItem> {
val result = mutableListOf<TimelineItem>() val result = mutableListOf<TimelineItem>()
var previousDay: String? = null var previousDay: String? = null
@@ -41,6 +41,9 @@ data class QMaxUiState(
val contactsLoading: Boolean = false, val contactsLoading: Boolean = false,
val contactsError: String? = null, val contactsError: String? = null,
val selectedChat: ChatDto? = null, val selectedChat: ChatDto? = null,
val initialUnreadCount: Int = 0,
val initialUnreadThrough: String? = null,
val initialMessageLoadComplete: Boolean = false,
val messages: List<MessageDto> = emptyList(), val messages: List<MessageDto> = emptyList(),
val chatSearchResults: List<MessageDto> = emptyList(), val chatSearchResults: List<MessageDto> = emptyList(),
val chatSearchLoading: Boolean = false, val chatSearchLoading: Boolean = false,
@@ -509,6 +512,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
) )
state.value = state.value.copy( state.value = state.value.copy(
selectedChat = openedChat, selectedChat = openedChat,
initialUnreadCount = chat.unreadCount.coerceAtLeast(0),
initialUnreadThrough = chat.lastMessageAt,
initialMessageLoadComplete = false,
chats = updatedChats, chats = updatedChats,
messages = emptyList(), messages = emptyList(),
chatSearchResults = emptyList(), chatSearchResults = emptyList(),
@@ -583,6 +589,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
} }
state.value = state.value.copy( state.value = state.value.copy(
selectedChat = null, selectedChat = null,
initialUnreadCount = 0,
initialUnreadThrough = null,
initialMessageLoadComplete = false,
chats = reorderedChats, chats = reorderedChats,
messages = emptyList(), messages = emptyList(),
chatSearchResults = emptyList(), chatSearchResults = emptyList(),
@@ -655,6 +664,10 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
if (error.code == 404 && recoverMissingChat(session, chat)) { if (error.code == 404 && recoverMissingChat(session, chat)) {
return@launchLoading return@launchLoading
} }
markInitialMessageLoadComplete(chat.id)
throw error
} catch (error: Throwable) {
markInitialMessageLoadComplete(chat.id)
throw error throw error
} }
if (state.value.selectedChat?.id == chat.id) { if (state.value.selectedChat?.id == chat.id) {
@@ -662,6 +675,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
selected?.let(::rememberChatRead) selected?.let(::rememberChatRead)
state.value = state.value.copy( state.value = state.value.copy(
selectedChat = selected, selectedChat = selected,
initialMessageLoadComplete = true,
chats = normalizeChats(state.value.chats, selectedChatId = chat.id), chats = normalizeChats(state.value.chats, selectedChatId = chat.id),
messages = fresh messages = fresh
) )
@@ -716,6 +730,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
if (replacement == null) { if (replacement == null) {
state.value = state.value.copy( state.value = state.value.copy(
chats = orderedFresh, chats = orderedFresh,
initialMessageLoadComplete = true,
error = null error = null
) )
repository.cacheChats(session, orderedFresh) repository.cacheChats(session, orderedFresh)
@@ -732,6 +747,9 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
) )
state.value = state.value.copy( state.value = state.value.copy(
selectedChat = openedChat, selectedChat = openedChat,
initialUnreadCount = maxOf(state.value.initialUnreadCount, replacement.unreadCount),
initialUnreadThrough = replacement.lastMessageAt ?: state.value.initialUnreadThrough,
initialMessageLoadComplete = false,
chats = chats, chats = chats,
messages = emptyList(), messages = emptyList(),
chatPresenceText = null, chatPresenceText = null,
@@ -747,6 +765,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
if (state.value.selectedChat?.id == openedChat.id) { if (state.value.selectedChat?.id == openedChat.id) {
state.value = state.value.copy( state.value = state.value.copy(
selectedChat = state.value.selectedChat?.copy(unreadCount = 0), selectedChat = state.value.selectedChat?.copy(unreadCount = 0),
initialMessageLoadComplete = true,
chats = normalizeChats(state.value.chats, selectedChatId = openedChat.id), chats = normalizeChats(state.value.chats, selectedChatId = openedChat.id),
messages = freshMessages 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) { fun upload(uri: Uri) = launchLoading(false) {
val session = requireSession() val session = requireSession()
val chat = state.value.selectedChat ?: return@launchLoading 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) { private fun syncChatRead(chatId: String) {
val session = state.value.session ?: return val session = state.value.session ?: return
viewModelScope.launch { viewModelScope.launch {