Доработаны reply/mention в группах и индикаторы чтения
- Исправлено отображение reply в группах: корректный автор и переход в профиль по @mention. - Добавлены подсветка и клики по mentions в пузырьках, а также передача username текущего пользователя в чат. - Реализован Telegram-подобный список упоминаний по символу '@' с аватарками и фильтрацией участников группы. - Исправлено поведение контекстного меню при открытии профиля и конфликт horizontal-scroll с жестом назад на экране поиска. - Для групп включена отправка PacketRead (как в desktop), чтобы read-статусы синхронизировались корректно. - В списке чатов скорректирована логика галочек: двойные синие только при реальном read-флаге последнего исходящего сообщения.
This commit is contained in:
@@ -973,6 +973,7 @@ fun MainScreen(
|
|||||||
currentUserPublicKey = accountPublicKey,
|
currentUserPublicKey = accountPublicKey,
|
||||||
currentUserPrivateKey = accountPrivateKey,
|
currentUserPrivateKey = accountPrivateKey,
|
||||||
currentUserName = accountName,
|
currentUserName = accountName,
|
||||||
|
currentUserUsername = accountUsername,
|
||||||
totalUnreadFromOthers = totalUnreadFromOthers,
|
totalUnreadFromOthers = totalUnreadFromOthers,
|
||||||
onBack = { popChatAndChildren() },
|
onBack = { popChatAndChildren() },
|
||||||
onUserProfileClick = { user ->
|
onUserProfileClick = { user ->
|
||||||
@@ -1049,7 +1050,8 @@ fun MainScreen(
|
|||||||
isVisible = isSearchVisible,
|
isVisible = isSearchVisible,
|
||||||
onBack = { navStack = navStack.filterNot { it is Screen.Search } },
|
onBack = { navStack = navStack.filterNot { it is Screen.Search } },
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
layer = 1
|
layer = 1,
|
||||||
|
deferToChildren = true
|
||||||
) {
|
) {
|
||||||
// Экран поиска
|
// Экран поиска
|
||||||
SearchScreen(
|
SearchScreen(
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ fun ChatDetailScreen(
|
|||||||
currentUserPublicKey: String,
|
currentUserPublicKey: String,
|
||||||
currentUserPrivateKey: String,
|
currentUserPrivateKey: String,
|
||||||
currentUserName: String = "",
|
currentUserName: String = "",
|
||||||
|
currentUserUsername: String = "",
|
||||||
totalUnreadFromOthers: Int = 0,
|
totalUnreadFromOthers: Int = 0,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
chatWallpaperId: String = "",
|
chatWallpaperId: String = "",
|
||||||
@@ -230,6 +231,8 @@ fun ChatDetailScreen(
|
|||||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
|
showContextMenu = false
|
||||||
|
contextMenuMessage = null
|
||||||
if (isGroupChat) {
|
if (isGroupChat) {
|
||||||
onGroupInfoClick(user)
|
onGroupInfoClick(user)
|
||||||
} else {
|
} else {
|
||||||
@@ -420,6 +423,9 @@ fun ChatDetailScreen(
|
|||||||
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
|
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
|
||||||
mutableStateOf<Set<String>>(emptySet())
|
mutableStateOf<Set<String>>(emptySet())
|
||||||
}
|
}
|
||||||
|
var mentionCandidates by remember(user.publicKey, currentUserPublicKey) {
|
||||||
|
mutableStateOf<List<MentionCandidate>>(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
// 📨 Forward: инициализируем ChatsListViewModel для получения списка диалогов
|
// 📨 Forward: инициализируем ChatsListViewModel для получения списка диалогов
|
||||||
LaunchedEffect(currentUserPublicKey, currentUserPrivateKey) {
|
LaunchedEffect(currentUserPublicKey, currentUserPrivateKey) {
|
||||||
@@ -431,6 +437,7 @@ fun ChatDetailScreen(
|
|||||||
LaunchedEffect(isGroupChat, user.publicKey, currentUserPublicKey) {
|
LaunchedEffect(isGroupChat, user.publicKey, currentUserPublicKey) {
|
||||||
if (!isGroupChat || user.publicKey.isBlank() || currentUserPublicKey.isBlank()) {
|
if (!isGroupChat || user.publicKey.isBlank() || currentUserPublicKey.isBlank()) {
|
||||||
groupAdminKeys = emptySet()
|
groupAdminKeys = emptySet()
|
||||||
|
mentionCandidates = emptyList()
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +447,23 @@ fun ChatDetailScreen(
|
|||||||
val adminKey = members.firstOrNull().orEmpty()
|
val adminKey = members.firstOrNull().orEmpty()
|
||||||
groupAdminKeys =
|
groupAdminKeys =
|
||||||
if (adminKey.isBlank()) emptySet() else setOf(adminKey)
|
if (adminKey.isBlank()) emptySet() else setOf(adminKey)
|
||||||
|
|
||||||
|
mentionCandidates =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
members.map { it.trim() }
|
||||||
|
.filter { it.isNotBlank() && !it.equals(currentUserPublicKey.trim(), ignoreCase = true) }
|
||||||
|
.distinct()
|
||||||
|
.mapNotNull { memberKey ->
|
||||||
|
val resolvedUser = viewModel.resolveUserForProfile(memberKey) ?: return@mapNotNull null
|
||||||
|
val normalizedUsername = resolvedUser.username.trim().trimStart('@')
|
||||||
|
MentionCandidate(
|
||||||
|
username = normalizedUsername,
|
||||||
|
title = resolvedUser.title.ifBlank { normalizedUsername },
|
||||||
|
publicKey = memberKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedBy { it.username.lowercase(Locale.ROOT) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Состояние выпадающего меню
|
// Состояние выпадающего меню
|
||||||
@@ -1810,6 +1834,9 @@ fun ChatDetailScreen(
|
|||||||
user.publicKey,
|
user.publicKey,
|
||||||
myPrivateKey =
|
myPrivateKey =
|
||||||
currentUserPrivateKey,
|
currentUserPrivateKey,
|
||||||
|
isGroupChat = isGroupChat,
|
||||||
|
mentionCandidates = mentionCandidates,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
inputFocusTrigger =
|
inputFocusTrigger =
|
||||||
inputFocusTrigger,
|
inputFocusTrigger,
|
||||||
suppressKeyboard =
|
suppressKeyboard =
|
||||||
@@ -2131,6 +2158,8 @@ fun ChatDetailScreen(
|
|||||||
senderPublicKeyForMessage,
|
senderPublicKeyForMessage,
|
||||||
senderName =
|
senderName =
|
||||||
message.senderName,
|
message.senderName,
|
||||||
|
isGroupChat =
|
||||||
|
isGroupChat,
|
||||||
showGroupSenderLabel =
|
showGroupSenderLabel =
|
||||||
isGroupChat &&
|
isGroupChat &&
|
||||||
!message.isOutgoing,
|
!message.isOutgoing,
|
||||||
@@ -2144,6 +2173,8 @@ fun ChatDetailScreen(
|
|||||||
),
|
),
|
||||||
currentUserPublicKey =
|
currentUserPublicKey =
|
||||||
currentUserPublicKey,
|
currentUserPublicKey,
|
||||||
|
currentUserUsername =
|
||||||
|
currentUserUsername,
|
||||||
avatarRepository =
|
avatarRepository =
|
||||||
avatarRepository,
|
avatarRepository,
|
||||||
onLongClick = {
|
onLongClick = {
|
||||||
@@ -2286,6 +2317,46 @@ fun ChatDetailScreen(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val resolvedUser = viewModel.resolveUserForProfile(senderPublicKey)
|
val resolvedUser = viewModel.resolveUserForProfile(senderPublicKey)
|
||||||
if (resolvedUser != null) {
|
if (resolvedUser != null) {
|
||||||
|
showContextMenu = false
|
||||||
|
contextMenuMessage = null
|
||||||
|
onUserProfileClick(resolvedUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMentionClick = { username ->
|
||||||
|
val normalizedUsername =
|
||||||
|
username.trim().trimStart('@').lowercase(Locale.ROOT)
|
||||||
|
if (normalizedUsername.isBlank()) return@MessageBubble
|
||||||
|
scope.launch {
|
||||||
|
val targetPublicKey =
|
||||||
|
mentionCandidates
|
||||||
|
.firstOrNull {
|
||||||
|
it.username.equals(
|
||||||
|
normalizedUsername,
|
||||||
|
ignoreCase = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?.publicKey
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: run {
|
||||||
|
val normalizedCurrent =
|
||||||
|
currentUserUsername.trim().trimStart('@').lowercase(Locale.ROOT)
|
||||||
|
if (normalizedCurrent == normalizedUsername && currentUserPublicKey.isNotBlank()) {
|
||||||
|
currentUserPublicKey
|
||||||
|
} else {
|
||||||
|
val normalizedOpponent =
|
||||||
|
user.username.trim().trimStart('@').lowercase(Locale.ROOT)
|
||||||
|
if (normalizedOpponent == normalizedUsername && user.publicKey.isNotBlank()) user.publicKey
|
||||||
|
else ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetPublicKey.isBlank()) return@launch
|
||||||
|
|
||||||
|
val resolvedUser = viewModel.resolveUserForProfile(targetPublicKey)
|
||||||
|
if (resolvedUser != null) {
|
||||||
|
showContextMenu = false
|
||||||
|
contextMenuMessage = null
|
||||||
onUserProfileClick(resolvedUser)
|
onUserProfileClick(resolvedUser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4192,9 +4192,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 👁️ Отправить read receipt собеседнику Как в архиве - просто отправляем PacketRead без
|
* 👁️ Отправить read receipt собеседнику.
|
||||||
* messageId Означает что мы прочитали все сообщения от этого собеседника 📁 SAVED MESSAGES: НЕ
|
* Desktop parity: отправляем и для групп (toPublicKey = group dialog key).
|
||||||
* отправляет read receipt для saved messages (нельзя слать самому себе)
|
* Не отправляем только для saved messages (нельзя слать самому себе).
|
||||||
*/
|
*/
|
||||||
private fun sendReadReceiptToOpponent() {
|
private fun sendReadReceiptToOpponent() {
|
||||||
// 🔥 Не отправляем read receipt если диалог не активен (как в архиве)
|
// 🔥 Не отправляем read receipt если диалог не активен (как в архиве)
|
||||||
@@ -4206,7 +4206,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
val sender = myPublicKey ?: return
|
val sender = myPublicKey ?: return
|
||||||
|
|
||||||
// 📁 НЕ отправляем read receipt для saved messages (opponent == sender)
|
// 📁 НЕ отправляем read receipt для saved messages (opponent == sender)
|
||||||
if (opponent == sender || isGroupDialogKey(opponent)) {
|
if (opponent == sender) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3805,19 +3805,14 @@ fun DialogItemContent(
|
|||||||
} else if (dialog.lastMessageFromMe == 1) {
|
} else if (dialog.lastMessageFromMe == 1) {
|
||||||
// Показываем статус только для исходящих сообщений
|
// Показываем статус только для исходящих сообщений
|
||||||
// (кроме Saved Messages)
|
// (кроме Saved Messages)
|
||||||
// 🔥 ПРАВИЛЬНАЯ ЛОГИКА (синхронизировано с
|
// Показываем READ только при реальном read-флаге
|
||||||
// ChatViewModel):
|
// последнего исходящего сообщения.
|
||||||
// - lastMessageDelivered == 3 → две синие галочки
|
|
||||||
// (прочитано собеседником)
|
|
||||||
// - lastMessageDelivered == 1 → одна галочка
|
|
||||||
// (доставлено)
|
|
||||||
// - lastMessageDelivered == 0 → часики
|
|
||||||
// (отправляется)
|
|
||||||
// - lastMessageDelivered == 2 → ошибка
|
|
||||||
val isReadByOpponent =
|
val isReadByOpponent =
|
||||||
dialog.lastMessageDelivered == 3 ||
|
when {
|
||||||
(dialog.lastMessageDelivered == 1 &&
|
dialog.lastMessageDelivered == 3 -> true
|
||||||
dialog.lastMessageRead == 1)
|
dialog.lastMessageDelivered != 1 -> false
|
||||||
|
else -> dialog.lastMessageRead == 1
|
||||||
|
}
|
||||||
|
|
||||||
when {
|
when {
|
||||||
isReadByOpponent -> {
|
isReadByOpponent -> {
|
||||||
|
|||||||
@@ -90,6 +90,22 @@ import kotlinx.coroutines.withContext
|
|||||||
* organization
|
* organization
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
private fun containsUserMention(text: String, username: String): Boolean {
|
||||||
|
val normalizedUsername = username.trim().trimStart('@')
|
||||||
|
if (normalizedUsername.isBlank()) return false
|
||||||
|
val mentionRegex =
|
||||||
|
Regex(
|
||||||
|
pattern = """(^|\s)@${Regex.escape(normalizedUsername)}(?=\b)""",
|
||||||
|
options = setOf(RegexOption.IGNORE_CASE)
|
||||||
|
)
|
||||||
|
return mentionRegex.containsMatchIn(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun containsAllMention(text: String): Boolean {
|
||||||
|
val mentionRegex = Regex("""(^|\s)@all(?=\b)""", setOf(RegexOption.IGNORE_CASE))
|
||||||
|
return mentionRegex.containsMatchIn(text)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Telegram-style layout для текста сообщения с временем. Если текст + время помещаются в одну
|
* Telegram-style layout для текста сообщения с временем. Если текст + время помещаются в одну
|
||||||
* строку - располагает их рядом. Если текст длинный и переносится - время встаёт в правый нижний
|
* строку - располагает их рядом. Если текст длинный и переносится - время встаёт в правый нижний
|
||||||
@@ -296,9 +312,11 @@ fun MessageBubble(
|
|||||||
privateKey: String = "",
|
privateKey: String = "",
|
||||||
senderPublicKey: String = "",
|
senderPublicKey: String = "",
|
||||||
senderName: String = "",
|
senderName: String = "",
|
||||||
|
isGroupChat: Boolean = false,
|
||||||
showGroupSenderLabel: Boolean = false,
|
showGroupSenderLabel: Boolean = false,
|
||||||
isGroupSenderAdmin: Boolean = false,
|
isGroupSenderAdmin: Boolean = false,
|
||||||
currentUserPublicKey: String = "",
|
currentUserPublicKey: String = "",
|
||||||
|
currentUserUsername: String = "",
|
||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
onLongClick: () -> Unit = {},
|
onLongClick: () -> Unit = {},
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
@@ -308,6 +326,7 @@ fun MessageBubble(
|
|||||||
onDelete: () -> Unit = {},
|
onDelete: () -> Unit = {},
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
||||||
|
onMentionClick: (username: String) -> Unit = {},
|
||||||
onGroupInviteOpen: (SearchUser) -> Unit = {},
|
onGroupInviteOpen: (SearchUser) -> Unit = {},
|
||||||
contextMenuContent: @Composable () -> Unit = {}
|
contextMenuContent: @Composable () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
@@ -342,10 +361,21 @@ fun MessageBubble(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Colors
|
// Colors
|
||||||
|
val isMentionedIncoming =
|
||||||
|
remember(message.text, message.isOutgoing, isGroupChat, currentUserUsername) {
|
||||||
|
!message.isOutgoing &&
|
||||||
|
isGroupChat &&
|
||||||
|
message.text.isNotBlank() &&
|
||||||
|
(containsAllMention(message.text) ||
|
||||||
|
containsUserMention(message.text, currentUserUsername))
|
||||||
|
}
|
||||||
|
|
||||||
val bubbleColor =
|
val bubbleColor =
|
||||||
remember(message.isOutgoing, isDarkTheme) {
|
remember(message.isOutgoing, isDarkTheme, isMentionedIncoming) {
|
||||||
if (message.isOutgoing) {
|
if (message.isOutgoing) {
|
||||||
PrimaryBlue
|
PrimaryBlue
|
||||||
|
} else if (isMentionedIncoming) {
|
||||||
|
if (isDarkTheme) Color(0xFF3A3422) else Color(0xFFFFF3CD)
|
||||||
} else {
|
} else {
|
||||||
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
|
||||||
}
|
}
|
||||||
@@ -365,6 +395,12 @@ fun MessageBubble(
|
|||||||
}
|
}
|
||||||
val linksEnabled = !isSelectionMode
|
val linksEnabled = !isSelectionMode
|
||||||
val textClickHandler: (() -> Unit)? = onClick
|
val textClickHandler: (() -> Unit)? = onClick
|
||||||
|
val mentionClickHandler: ((String) -> Unit)? =
|
||||||
|
if (linksEnabled) {
|
||||||
|
{ username -> onMentionClick(username) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
val timeColor =
|
val timeColor =
|
||||||
remember(message.isOutgoing, isDarkTheme) {
|
remember(message.isOutgoing, isDarkTheme) {
|
||||||
@@ -803,8 +839,10 @@ fun MessageBubble(
|
|||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
chachaKey = message.chachaKey,
|
chachaKey = message.chachaKey,
|
||||||
privateKey = privateKey,
|
privateKey = privateKey,
|
||||||
|
linksEnabled = linksEnabled,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
onForwardedSenderClick = onForwardedSenderClick
|
onForwardedSenderClick = onForwardedSenderClick,
|
||||||
|
onMentionClick = onMentionClick
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
}
|
}
|
||||||
@@ -891,6 +929,10 @@ fun MessageBubble(
|
|||||||
linkColor,
|
linkColor,
|
||||||
enableLinks =
|
enableLinks =
|
||||||
linksEnabled,
|
linksEnabled,
|
||||||
|
enableMentions =
|
||||||
|
true,
|
||||||
|
onMentionClick =
|
||||||
|
mentionClickHandler,
|
||||||
onClick =
|
onClick =
|
||||||
textClickHandler,
|
textClickHandler,
|
||||||
onLongClick =
|
onLongClick =
|
||||||
@@ -979,6 +1021,8 @@ fun MessageBubble(
|
|||||||
fontSize = 17.sp,
|
fontSize = 17.sp,
|
||||||
linkColor = linkColor,
|
linkColor = linkColor,
|
||||||
enableLinks = linksEnabled,
|
enableLinks = linksEnabled,
|
||||||
|
enableMentions = true,
|
||||||
|
onMentionClick = mentionClickHandler,
|
||||||
onClick = textClickHandler,
|
onClick = textClickHandler,
|
||||||
onLongClick =
|
onLongClick =
|
||||||
onLongClick // 🔥
|
onLongClick // 🔥
|
||||||
@@ -1079,6 +1123,8 @@ fun MessageBubble(
|
|||||||
fontSize = 17.sp,
|
fontSize = 17.sp,
|
||||||
linkColor = linkColor,
|
linkColor = linkColor,
|
||||||
enableLinks = linksEnabled,
|
enableLinks = linksEnabled,
|
||||||
|
enableMentions = true,
|
||||||
|
onMentionClick = mentionClickHandler,
|
||||||
onClick = textClickHandler,
|
onClick = textClickHandler,
|
||||||
onLongClick =
|
onLongClick =
|
||||||
onLongClick // 🔥
|
onLongClick // 🔥
|
||||||
@@ -2167,8 +2213,10 @@ fun ForwardedMessagesBubble(
|
|||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
chachaKey: String = "",
|
chachaKey: String = "",
|
||||||
privateKey: String = "",
|
privateKey: String = "",
|
||||||
|
linksEnabled: Boolean = true,
|
||||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||||
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}
|
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
||||||
|
onMentionClick: (username: String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val backgroundColor =
|
val backgroundColor =
|
||||||
if (isOutgoing) Color.Black.copy(alpha = 0.1f)
|
if (isOutgoing) Color.Black.copy(alpha = 0.1f)
|
||||||
@@ -2277,7 +2325,9 @@ fun ForwardedMessagesBubble(
|
|||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
maxLines = 50,
|
maxLines = 50,
|
||||||
overflow = android.text.TextUtils.TruncateAt.END,
|
overflow = android.text.TextUtils.TruncateAt.END,
|
||||||
enableLinks = true
|
enableLinks = linksEnabled,
|
||||||
|
enableMentions = true,
|
||||||
|
onMentionClick = if (linksEnabled) onMentionClick else null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ import android.view.inputmethod.InputMethodManager
|
|||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
@@ -25,6 +30,7 @@ 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.draw.shadow
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -35,8 +41,10 @@ import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
|
|||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import com.rosetta.messenger.network.AttachmentType
|
import com.rosetta.messenger.network.AttachmentType
|
||||||
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
import com.rosetta.messenger.ui.components.AppleEmojiTextField
|
||||||
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
|
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
|
||||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||||
import com.rosetta.messenger.ui.chats.models.*
|
import com.rosetta.messenger.ui.chats.models.*
|
||||||
@@ -45,6 +53,7 @@ import com.rosetta.messenger.ui.chats.utils.*
|
|||||||
import com.rosetta.messenger.ui.chats.ChatViewModel
|
import com.rosetta.messenger.ui.chats.ChatViewModel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Message input bar and related components
|
* Message input bar and related components
|
||||||
@@ -59,6 +68,12 @@ import kotlinx.coroutines.launch
|
|||||||
* 5. Input grows upward for multi-line (up to 6 lines)
|
* 5. Input grows upward for multi-line (up to 6 lines)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
data class MentionCandidate(
|
||||||
|
val username: String,
|
||||||
|
val title: String,
|
||||||
|
val publicKey: String
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageInputBar(
|
fun MessageInputBar(
|
||||||
@@ -85,6 +100,9 @@ fun MessageInputBar(
|
|||||||
myPublicKey: String = "",
|
myPublicKey: String = "",
|
||||||
opponentPublicKey: String = "",
|
opponentPublicKey: String = "",
|
||||||
myPrivateKey: String = "",
|
myPrivateKey: String = "",
|
||||||
|
isGroupChat: Boolean = false,
|
||||||
|
mentionCandidates: List<MentionCandidate> = emptyList(),
|
||||||
|
avatarRepository: AvatarRepository? = null,
|
||||||
inputFocusTrigger: Int = 0,
|
inputFocusTrigger: Int = 0,
|
||||||
suppressKeyboard: Boolean = false
|
suppressKeyboard: Boolean = false
|
||||||
) {
|
) {
|
||||||
@@ -199,6 +217,48 @@ fun MessageInputBar(
|
|||||||
|
|
||||||
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
|
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
|
||||||
var isSending by remember { mutableStateOf(false) }
|
var isSending by remember { mutableStateOf(false) }
|
||||||
|
val mentionPattern = remember { Regex("@([\\w\\d_]*)$") }
|
||||||
|
val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") }
|
||||||
|
val mentionMatch = remember(value, isGroupChat) { if (isGroupChat) mentionPattern.find(value) else null }
|
||||||
|
|
||||||
|
val mentionQuery =
|
||||||
|
remember(mentionMatch) {
|
||||||
|
mentionMatch?.groupValues?.getOrNull(1)?.lowercase(Locale.ROOT).orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
val shouldShowMentionSuggestions = remember(mentionMatch, isGroupChat) { isGroupChat && mentionMatch != null }
|
||||||
|
|
||||||
|
val mentionSuggestions =
|
||||||
|
remember(
|
||||||
|
value,
|
||||||
|
mentionCandidates,
|
||||||
|
mentionQuery,
|
||||||
|
shouldShowMentionSuggestions
|
||||||
|
) {
|
||||||
|
if (!shouldShowMentionSuggestions) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
val mentionedInText =
|
||||||
|
mentionedPattern
|
||||||
|
.findAll(value)
|
||||||
|
.mapNotNull { match ->
|
||||||
|
match.groupValues.getOrNull(1)?.takeIf { it.isNotBlank() }?.lowercase(Locale.ROOT)
|
||||||
|
}
|
||||||
|
.toSet()
|
||||||
|
|
||||||
|
val result = mutableListOf<MentionCandidate>()
|
||||||
|
|
||||||
|
mentionCandidates.forEach { candidate ->
|
||||||
|
val normalized = candidate.username.trim().trimStart('@').lowercase(Locale.ROOT)
|
||||||
|
if (normalized.isBlank()) return@forEach
|
||||||
|
if (!normalized.startsWith(mentionQuery, ignoreCase = true)) return@forEach
|
||||||
|
if (mentionedInText.contains(normalized)) return@forEach
|
||||||
|
if (result.any { it.username.equals(normalized, ignoreCase = true) }) return@forEach
|
||||||
|
result.add(candidate.copy(username = normalized))
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close keyboard when user is blocked
|
// Close keyboard when user is blocked
|
||||||
LaunchedEffect(isBlocked) {
|
LaunchedEffect(isBlocked) {
|
||||||
@@ -276,6 +336,18 @@ fun MessageInputBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onSelectMention(mention: MentionCandidate) {
|
||||||
|
val mentionToken = "@${mention.username} "
|
||||||
|
val updated =
|
||||||
|
if (mentionPattern.containsMatchIn(value)) {
|
||||||
|
mentionPattern.replace(value, mentionToken)
|
||||||
|
} else {
|
||||||
|
"$value$mentionToken"
|
||||||
|
}
|
||||||
|
onValueChange(updated)
|
||||||
|
editTextView?.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false }) {
|
Column(modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false }) {
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
// BLOCKED CHAT FOOTER
|
// BLOCKED CHAT FOOTER
|
||||||
@@ -337,6 +409,138 @@ fun MessageInputBar(
|
|||||||
)
|
)
|
||||||
.then(if (shouldAddNavBarPadding) Modifier.navigationBarsPadding() else Modifier)
|
.then(if (shouldAddNavBarPadding) Modifier.navigationBarsPadding() else Modifier)
|
||||||
) {
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = mentionSuggestions.isNotEmpty(),
|
||||||
|
enter = fadeIn(animationSpec = tween(120)) + expandVertically(animationSpec = tween(120)),
|
||||||
|
exit = fadeOut(animationSpec = tween(100)) + shrinkVertically(animationSpec = tween(100))
|
||||||
|
) {
|
||||||
|
val mentionCardShape = RoundedCornerShape(14.dp)
|
||||||
|
val mentionCardColor = if (isDarkTheme) Color(0xFF272829) else Color.White
|
||||||
|
val mentionBorderColor =
|
||||||
|
if (isDarkTheme) Color(0xFF1C1D1F) else Color(0xFFF0F0F2)
|
||||||
|
val mentionDividerColor =
|
||||||
|
if (isDarkTheme) Color(0xFF1C1D1F) else Color(0xFFF5F5F5)
|
||||||
|
val mentionPrimaryText = if (isDarkTheme) Color.White else Color(0xFF222222)
|
||||||
|
val mentionSecondaryText = if (isDarkTheme) Color.White.copy(alpha = 0.47f) else Color(0xFF676B70)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 10.dp, vertical = 5.dp)
|
||||||
|
.shadow(
|
||||||
|
elevation = if (isDarkTheme) 6.dp else 3.dp,
|
||||||
|
shape = mentionCardShape,
|
||||||
|
clip = false
|
||||||
|
)
|
||||||
|
.clip(mentionCardShape)
|
||||||
|
.background(color = mentionCardColor)
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
color = mentionBorderColor,
|
||||||
|
shape = mentionCardShape
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(max = 196.dp)
|
||||||
|
.padding(vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
mentionSuggestions,
|
||||||
|
key = { candidate ->
|
||||||
|
if (candidate.publicKey.isNotBlank()) candidate.publicKey else candidate.username
|
||||||
|
}
|
||||||
|
) { candidate ->
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelectMention(candidate) }
|
||||||
|
.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (candidate.publicKey.isNotBlank()) {
|
||||||
|
AvatarImage(
|
||||||
|
publicKey = candidate.publicKey,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
|
size = 34.dp,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
displayName = candidate.title
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val fallbackColor = PrimaryBlue
|
||||||
|
val fallbackText =
|
||||||
|
candidate.title.firstOrNull()?.uppercaseChar()?.toString() ?: "A"
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(34.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(fallbackColor),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = fallbackText,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(9.dp))
|
||||||
|
|
||||||
|
val titleText = candidate.title.ifBlank { candidate.username }
|
||||||
|
val usernameText = "@${candidate.username}"
|
||||||
|
val showInlineUsername = candidate.username.isNotBlank()
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = titleText,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = mentionPrimaryText,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false)
|
||||||
|
)
|
||||||
|
if (showInlineUsername) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = usernameText,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = mentionSecondaryText,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.alpha(0.95f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate != mentionSuggestions.last()) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 52.dp)
|
||||||
|
.height(0.5.dp)
|
||||||
|
.background(mentionDividerColor)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// REPLY PANEL
|
// REPLY PANEL
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = hasReply,
|
visible = hasReply,
|
||||||
|
|||||||
@@ -353,7 +353,10 @@ fun AppleEmojiText(
|
|||||||
maxLines: Int = Int.MAX_VALUE,
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
overflow: android.text.TextUtils.TruncateAt? = null,
|
overflow: android.text.TextUtils.TruncateAt? = null,
|
||||||
linkColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color(0xFF54A9EB), // Telegram-style blue
|
linkColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color(0xFF54A9EB), // Telegram-style blue
|
||||||
|
mentionColor: androidx.compose.ui.graphics.Color = linkColor,
|
||||||
enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки
|
enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки
|
||||||
|
enableMentions: Boolean = false,
|
||||||
|
onMentionClick: ((String) -> Unit)? = null,
|
||||||
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
|
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
|
||||||
onLongClick: (() -> Unit)? = null // 🔥 Callback для long press (selection в MessageBubble)
|
onLongClick: (() -> Unit)? = null // 🔥 Callback для long press (selection в MessageBubble)
|
||||||
) {
|
) {
|
||||||
@@ -394,6 +397,9 @@ fun AppleEmojiText(
|
|||||||
// иначе тапы по тексту могут "съедаться" и не доходить до onClick.
|
// иначе тапы по тексту могут "съедаться" и не доходить до onClick.
|
||||||
enableClickableLinks(false, onLongClick)
|
enableClickableLinks(false, onLongClick)
|
||||||
}
|
}
|
||||||
|
setMentionColor(mentionColor.toArgb())
|
||||||
|
enableMentionHighlight(enableMentions)
|
||||||
|
setOnMentionClickListener(onMentionClick)
|
||||||
// 🔥 Поддержка обычного tap (например, для selection mode)
|
// 🔥 Поддержка обычного tap (например, для selection mode)
|
||||||
setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
|
setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
|
||||||
}
|
}
|
||||||
@@ -416,6 +422,9 @@ fun AppleEmojiText(
|
|||||||
// иначе тапы по тексту могут "съедаться" и не доходить до onClick.
|
// иначе тапы по тексту могут "съедаться" и не доходить до onClick.
|
||||||
view.enableClickableLinks(false, onLongClick)
|
view.enableClickableLinks(false, onLongClick)
|
||||||
}
|
}
|
||||||
|
view.setMentionColor(mentionColor.toArgb())
|
||||||
|
view.enableMentionHighlight(enableMentions)
|
||||||
|
view.setOnMentionClickListener(onMentionClick)
|
||||||
// 🔥 Обновляем tap callback, чтобы не было stale lambda
|
// 🔥 Обновляем tap callback, чтобы не было stale lambda
|
||||||
view.setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
|
view.setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
|
||||||
},
|
},
|
||||||
@@ -452,10 +461,14 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
private val PHONE_PATTERN = Pattern.compile(
|
private val PHONE_PATTERN = Pattern.compile(
|
||||||
"\\+?[0-9][\\s\\-()0-9]{6,}[0-9]"
|
"\\+?[0-9][\\s\\-()0-9]{6,}[0-9]"
|
||||||
)
|
)
|
||||||
|
private val MENTION_PATTERN = Pattern.compile("(?<![A-Za-z0-9_])@[A-Za-z0-9_]{1,32}(?![A-Za-z0-9_])")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var linkColorValue: Int = 0xFF54A9EB.toInt() // Default Telegram blue
|
private var linkColorValue: Int = 0xFF54A9EB.toInt() // Default Telegram blue
|
||||||
private var linksEnabled: Boolean = false
|
private var linksEnabled: Boolean = false
|
||||||
|
private var mentionColorValue: Int = 0xFF54A9EB.toInt()
|
||||||
|
private var mentionsEnabled: Boolean = false
|
||||||
|
private var mentionClickCallback: ((String) -> Unit)? = null
|
||||||
|
|
||||||
// 🔥 Long press callback для selection в MessageBubble
|
// 🔥 Long press callback для selection в MessageBubble
|
||||||
var onLongClickCallback: (() -> Unit)? = null
|
var onLongClickCallback: (() -> Unit)? = null
|
||||||
@@ -490,6 +503,20 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
linkColorValue = color
|
linkColorValue = color
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMentionColor(color: Int) {
|
||||||
|
mentionColorValue = color
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enableMentionHighlight(enable: Boolean) {
|
||||||
|
mentionsEnabled = enable
|
||||||
|
updateMovementMethod()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setOnMentionClickListener(listener: ((String) -> Unit)?) {
|
||||||
|
mentionClickCallback = listener
|
||||||
|
updateMovementMethod()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 Включить/выключить кликабельные ссылки
|
* 🔥 Включить/выключить кликабельные ссылки
|
||||||
* @param enable - включить ссылки
|
* @param enable - включить ссылки
|
||||||
@@ -498,7 +525,12 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
fun enableClickableLinks(enable: Boolean, onLongClick: (() -> Unit)? = null) {
|
fun enableClickableLinks(enable: Boolean, onLongClick: (() -> Unit)? = null) {
|
||||||
linksEnabled = enable
|
linksEnabled = enable
|
||||||
onLongClickCallback = onLongClick
|
onLongClickCallback = onLongClick
|
||||||
if (enable) {
|
updateMovementMethod()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMovementMethod() {
|
||||||
|
val hasMentionClicks = mentionsEnabled && mentionClickCallback != null
|
||||||
|
if (linksEnabled || hasMentionClicks) {
|
||||||
movementMethod = LinkMovementMethod.getInstance()
|
movementMethod = LinkMovementMethod.getInstance()
|
||||||
// Убираем highlight при клике
|
// Убираем highlight при клике
|
||||||
highlightColor = android.graphics.Color.TRANSPARENT
|
highlightColor = android.graphics.Color.TRANSPARENT
|
||||||
@@ -572,6 +604,10 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mentionsEnabled) {
|
||||||
|
addMentionHighlights(spannable)
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи
|
// 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи
|
||||||
if (linksEnabled) {
|
if (linksEnabled) {
|
||||||
addClickableLinks(spannable)
|
addClickableLinks(spannable)
|
||||||
@@ -584,6 +620,47 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
* 🔥 Добавляет кликабельные ссылки (URL, email, телефоны) в spannable
|
* 🔥 Добавляет кликабельные ссылки (URL, email, телефоны) в spannable
|
||||||
*/
|
*/
|
||||||
private enum class LinkType { URL, EMAIL, PHONE }
|
private enum class LinkType { URL, EMAIL, PHONE }
|
||||||
|
|
||||||
|
private fun addMentionHighlights(spannable: SpannableStringBuilder) {
|
||||||
|
val textStr = spannable.toString()
|
||||||
|
val mentionMatcher = MENTION_PATTERN.matcher(textStr)
|
||||||
|
while (mentionMatcher.find()) {
|
||||||
|
val start = mentionMatcher.start()
|
||||||
|
val end = mentionMatcher.end()
|
||||||
|
val overlapsClickable = spannable.getSpans(start, end, ClickableSpan::class.java).isNotEmpty()
|
||||||
|
if (overlapsClickable) continue
|
||||||
|
val mentionText = textStr.substring(start, end).trim().trimStart('@')
|
||||||
|
val callback = mentionClickCallback
|
||||||
|
if (callback != null) {
|
||||||
|
val clickableSpan = object : ClickableSpan() {
|
||||||
|
override fun onClick(widget: View) {
|
||||||
|
if (mentionText.isNotBlank()) {
|
||||||
|
callback.invoke(mentionText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateDrawState(ds: TextPaint) {
|
||||||
|
super.updateDrawState(ds)
|
||||||
|
ds.color = mentionColorValue
|
||||||
|
ds.isUnderlineText = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spannable.setSpan(
|
||||||
|
clickableSpan,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
spannable.setSpan(
|
||||||
|
ForegroundColorSpan(mentionColorValue),
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun addClickableLinks(spannable: SpannableStringBuilder) {
|
private fun addClickableLinks(spannable: SpannableStringBuilder) {
|
||||||
val textStr = spannable.toString()
|
val textStr = spannable.toString()
|
||||||
|
|||||||
Reference in New Issue
Block a user