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