Доработаны 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,
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user