Доработаны 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, 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(

View File

@@ -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)
} }
} }

View File

@@ -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
} }

View File

@@ -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 -> {

View File

@@ -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
) )
} }
} }

View File

@@ -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,

View File

@@ -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()