diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 354ab77..e88ee14 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -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( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 5c9fb76..a54cd72 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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>(emptySet()) } + var mentionCandidates by remember(user.publicKey, currentUserPublicKey) { + mutableStateOf>(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) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index c9db9f9..4c539d8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -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 } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 3ca56b4..d7f146f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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 -> { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 0f578d1..bbc9aa1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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 ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index c22370f..030d0fc 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -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 = 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() + + 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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 449a4a1..f9428b2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -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("(? 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()