Improve Android chat navigation and sharing
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user