Доработаны reply/mention в группах и индикаторы чтения

- Исправлено отображение reply в группах: корректный автор и переход в профиль по @mention.
- Добавлены подсветка и клики по mentions в пузырьках, а также передача username текущего пользователя в чат.
- Реализован Telegram-подобный список упоминаний по символу '@' с аватарками и фильтрацией участников группы.
- Исправлено поведение контекстного меню при открытии профиля и конфликт horizontal-scroll с жестом назад на экране поиска.
- Для групп включена отправка PacketRead (как в desktop), чтобы read-статусы синхронизировались корректно.
- В списке чатов скорректирована логика галочек: двойные синие только при реальном read-флаге последнего исходящего сообщения.
This commit is contained in:
2026-03-05 19:51:49 +05:00
parent e68a9aac53
commit 5de0777063
7 changed files with 421 additions and 22 deletions

View File

@@ -973,6 +973,7 @@ fun MainScreen(
currentUserPublicKey = accountPublicKey,
currentUserPrivateKey = accountPrivateKey,
currentUserName = accountName,
currentUserUsername = accountUsername,
totalUnreadFromOthers = totalUnreadFromOthers,
onBack = { popChatAndChildren() },
onUserProfileClick = { user ->
@@ -1049,7 +1050,8 @@ fun MainScreen(
isVisible = isSearchVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Search } },
isDarkTheme = isDarkTheme,
layer = 1
layer = 1,
deferToChildren = true
) {
// Экран поиска
SearchScreen(

View File

@@ -122,6 +122,7 @@ fun ChatDetailScreen(
currentUserPublicKey: String,
currentUserPrivateKey: String,
currentUserName: String = "",
currentUserUsername: String = "",
totalUnreadFromOthers: Int = 0,
isDarkTheme: Boolean,
chatWallpaperId: String = "",
@@ -230,6 +231,8 @@ fun ChatDetailScreen(
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
showContextMenu = false
contextMenuMessage = null
if (isGroupChat) {
onGroupInfoClick(user)
} else {
@@ -420,6 +423,9 @@ fun ChatDetailScreen(
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Set<String>>(emptySet())
}
var mentionCandidates by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<List<MentionCandidate>>(emptyList())
}
// 📨 Forward: инициализируем ChatsListViewModel для получения списка диалогов
LaunchedEffect(currentUserPublicKey, currentUserPrivateKey) {
@@ -431,6 +437,7 @@ fun ChatDetailScreen(
LaunchedEffect(isGroupChat, user.publicKey, currentUserPublicKey) {
if (!isGroupChat || user.publicKey.isBlank() || currentUserPublicKey.isBlank()) {
groupAdminKeys = emptySet()
mentionCandidates = emptyList()
return@LaunchedEffect
}
@@ -440,6 +447,23 @@ fun ChatDetailScreen(
val adminKey = members.firstOrNull().orEmpty()
groupAdminKeys =
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,
myPrivateKey =
currentUserPrivateKey,
isGroupChat = isGroupChat,
mentionCandidates = mentionCandidates,
avatarRepository = avatarRepository,
inputFocusTrigger =
inputFocusTrigger,
suppressKeyboard =
@@ -2131,6 +2158,8 @@ fun ChatDetailScreen(
senderPublicKeyForMessage,
senderName =
message.senderName,
isGroupChat =
isGroupChat,
showGroupSenderLabel =
isGroupChat &&
!message.isOutgoing,
@@ -2144,6 +2173,8 @@ fun ChatDetailScreen(
),
currentUserPublicKey =
currentUserPublicKey,
currentUserUsername =
currentUserUsername,
avatarRepository =
avatarRepository,
onLongClick = {
@@ -2286,6 +2317,46 @@ fun ChatDetailScreen(
scope.launch {
val resolvedUser = viewModel.resolveUserForProfile(senderPublicKey)
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)
}
}

View File

@@ -4192,9 +4192,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
/**
* 👁️ Отправить read receipt собеседнику Как в архиве - просто отправляем PacketRead без
* messageId Означает что мы прочитали все сообщения от этого собеседника 📁 SAVED MESSAGES: НЕ
* отправляет read receipt для saved messages (нельзя слать самому себе)
* 👁️ Отправить read receipt собеседнику.
* Desktop parity: отправляем и для групп (toPublicKey = group dialog key).
* Не отправляем только для saved messages (нельзя слать самому себе).
*/
private fun sendReadReceiptToOpponent() {
// 🔥 Не отправляем read receipt если диалог не активен (как в архиве)
@@ -4206,7 +4206,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val sender = myPublicKey ?: return
// 📁 НЕ отправляем read receipt для saved messages (opponent == sender)
if (opponent == sender || isGroupDialogKey(opponent)) {
if (opponent == sender) {
return
}

View File

@@ -3805,19 +3805,14 @@ fun DialogItemContent(
} else if (dialog.lastMessageFromMe == 1) {
// Показываем статус только для исходящих сообщений
// (кроме Saved Messages)
// 🔥 ПРАВИЛЬНАЯ ЛОГИКА (синхронизировано с
// ChatViewModel):
// - lastMessageDelivered == 3 → две синие галочки
// (прочитано собеседником)
// - lastMessageDelivered == 1 → одна галочка
// (доставлено)
// - lastMessageDelivered == 0 → часики
// (отправляется)
// - lastMessageDelivered == 2 → ошибка
// Показываем READ только при реальном read-флаге
// последнего исходящего сообщения.
val isReadByOpponent =
dialog.lastMessageDelivered == 3 ||
(dialog.lastMessageDelivered == 1 &&
dialog.lastMessageRead == 1)
when {
dialog.lastMessageDelivered == 3 -> true
dialog.lastMessageDelivered != 1 -> false
else -> dialog.lastMessageRead == 1
}
when {
isReadByOpponent -> {

View File

@@ -90,6 +90,22 @@ import kotlinx.coroutines.withContext
* 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 для текста сообщения с временем. Если текст + время помещаются в одну
* строку - располагает их рядом. Если текст длинный и переносится - время встаёт в правый нижний
@@ -296,9 +312,11 @@ fun MessageBubble(
privateKey: String = "",
senderPublicKey: String = "",
senderName: String = "",
isGroupChat: Boolean = false,
showGroupSenderLabel: Boolean = false,
isGroupSenderAdmin: Boolean = false,
currentUserPublicKey: String = "",
currentUserUsername: String = "",
avatarRepository: AvatarRepository? = null,
onLongClick: () -> Unit = {},
onClick: () -> Unit = {},
@@ -308,6 +326,7 @@ fun MessageBubble(
onDelete: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
onMentionClick: (username: String) -> Unit = {},
onGroupInviteOpen: (SearchUser) -> Unit = {},
contextMenuContent: @Composable () -> Unit = {}
) {
@@ -342,10 +361,21 @@ fun MessageBubble(
)
// 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 =
remember(message.isOutgoing, isDarkTheme) {
remember(message.isOutgoing, isDarkTheme, isMentionedIncoming) {
if (message.isOutgoing) {
PrimaryBlue
} else if (isMentionedIncoming) {
if (isDarkTheme) Color(0xFF3A3422) else Color(0xFFFFF3CD)
} else {
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
}
@@ -365,6 +395,12 @@ fun MessageBubble(
}
val linksEnabled = !isSelectionMode
val textClickHandler: (() -> Unit)? = onClick
val mentionClickHandler: ((String) -> Unit)? =
if (linksEnabled) {
{ username -> onMentionClick(username) }
} else {
null
}
val timeColor =
remember(message.isOutgoing, isDarkTheme) {
@@ -803,8 +839,10 @@ fun MessageBubble(
isDarkTheme = isDarkTheme,
chachaKey = message.chachaKey,
privateKey = privateKey,
linksEnabled = linksEnabled,
onImageClick = onImageClick,
onForwardedSenderClick = onForwardedSenderClick
onForwardedSenderClick = onForwardedSenderClick,
onMentionClick = onMentionClick
)
Spacer(modifier = Modifier.height(4.dp))
}
@@ -891,6 +929,10 @@ fun MessageBubble(
linkColor,
enableLinks =
linksEnabled,
enableMentions =
true,
onMentionClick =
mentionClickHandler,
onClick =
textClickHandler,
onLongClick =
@@ -979,6 +1021,8 @@ fun MessageBubble(
fontSize = 17.sp,
linkColor = linkColor,
enableLinks = linksEnabled,
enableMentions = true,
onMentionClick = mentionClickHandler,
onClick = textClickHandler,
onLongClick =
onLongClick // 🔥
@@ -1079,6 +1123,8 @@ fun MessageBubble(
fontSize = 17.sp,
linkColor = linkColor,
enableLinks = linksEnabled,
enableMentions = true,
onMentionClick = mentionClickHandler,
onClick = textClickHandler,
onLongClick =
onLongClick // 🔥
@@ -2167,8 +2213,10 @@ fun ForwardedMessagesBubble(
isDarkTheme: Boolean,
chachaKey: String = "",
privateKey: String = "",
linksEnabled: Boolean = true,
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
onMentionClick: (username: String) -> Unit = {}
) {
val backgroundColor =
if (isOutgoing) Color.Black.copy(alpha = 0.1f)
@@ -2277,7 +2325,9 @@ fun ForwardedMessagesBubble(
fontSize = 14.sp,
maxLines = 50,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = true
enableLinks = linksEnabled,
enableMentions = true,
onMentionClick = if (linksEnabled) onMentionClick else null
)
}
}

View File

@@ -5,11 +5,16 @@ import android.view.inputmethod.InputMethodManager
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.material3.*
import androidx.compose.ui.draw.alpha
import com.rosetta.messenger.ui.icons.TelegramIcons
import androidx.compose.runtime.*
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.LocalFocusManager
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -35,8 +41,10 @@ import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import coil.compose.AsyncImage
import coil.request.ImageRequest
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.AppleEmojiTextField
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
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 kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Locale
/**
* 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)
*/
data class MentionCandidate(
val username: String,
val title: String,
val publicKey: String
)
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun MessageInputBar(
@@ -85,6 +100,9 @@ fun MessageInputBar(
myPublicKey: String = "",
opponentPublicKey: String = "",
myPrivateKey: String = "",
isGroupChat: Boolean = false,
mentionCandidates: List<MentionCandidate> = emptyList(),
avatarRepository: AvatarRepository? = null,
inputFocusTrigger: Int = 0,
suppressKeyboard: Boolean = false
) {
@@ -199,6 +217,48 @@ fun MessageInputBar(
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
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
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 }) {
if (isBlocked) {
// BLOCKED CHAT FOOTER
@@ -337,6 +409,138 @@ fun MessageInputBar(
)
.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
AnimatedVisibility(
visible = hasReply,

View File

@@ -353,7 +353,10 @@ fun AppleEmojiText(
maxLines: Int = Int.MAX_VALUE,
overflow: android.text.TextUtils.TruncateAt? = null,
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, // 🔥 Включить кликабельные ссылки
enableMentions: Boolean = false,
onMentionClick: ((String) -> Unit)? = null,
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
onLongClick: (() -> Unit)? = null // 🔥 Callback для long press (selection в MessageBubble)
) {
@@ -394,6 +397,9 @@ fun AppleEmojiText(
// иначе тапы по тексту могут "съедаться" и не доходить до onClick.
enableClickableLinks(false, onLongClick)
}
setMentionColor(mentionColor.toArgb())
enableMentionHighlight(enableMentions)
setOnMentionClickListener(onMentionClick)
// 🔥 Поддержка обычного tap (например, для selection mode)
setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
}
@@ -416,6 +422,9 @@ fun AppleEmojiText(
// иначе тапы по тексту могут "съедаться" и не доходить до onClick.
view.enableClickableLinks(false, onLongClick)
}
view.setMentionColor(mentionColor.toArgb())
view.enableMentionHighlight(enableMentions)
view.setOnMentionClickListener(onMentionClick)
// 🔥 Обновляем tap callback, чтобы не было stale lambda
view.setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
},
@@ -452,10 +461,14 @@ class AppleEmojiTextView @JvmOverloads constructor(
private val PHONE_PATTERN = Pattern.compile(
"\\+?[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 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
var onLongClickCallback: (() -> Unit)? = null
@@ -490,6 +503,20 @@ class AppleEmojiTextView @JvmOverloads constructor(
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 - включить ссылки
@@ -498,7 +525,12 @@ class AppleEmojiTextView @JvmOverloads constructor(
fun enableClickableLinks(enable: Boolean, onLongClick: (() -> Unit)? = null) {
linksEnabled = enable
onLongClickCallback = onLongClick
if (enable) {
updateMovementMethod()
}
private fun updateMovementMethod() {
val hasMentionClicks = mentionsEnabled && mentionClickCallback != null
if (linksEnabled || hasMentionClicks) {
movementMethod = LinkMovementMethod.getInstance()
// Убираем highlight при клике
highlightColor = android.graphics.Color.TRANSPARENT
@@ -572,6 +604,10 @@ class AppleEmojiTextView @JvmOverloads constructor(
}
}
if (mentionsEnabled) {
addMentionHighlights(spannable)
}
// 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи
if (linksEnabled) {
addClickableLinks(spannable)
@@ -584,6 +620,47 @@ class AppleEmojiTextView @JvmOverloads constructor(
* 🔥 Добавляет кликабельные ссылки (URL, email, телефоны) в spannable
*/
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) {
val textStr = spannable.toString()