fix: enable clickable links in AppleEmojiText for URLs, emails, and phone numbers
This commit is contained in:
@@ -290,6 +290,13 @@ fun MessageBubble(
|
||||
else if (isDarkTheme) Color.White else Color(0xFF000000)
|
||||
}
|
||||
|
||||
// 🔗 Цвет ссылок: для исходящих (синий фон) - светлый, для входящих - стандартный синий
|
||||
val linkColor =
|
||||
remember(message.isOutgoing, isDarkTheme) {
|
||||
if (message.isOutgoing) Color(0xFFB3E5FC) // Светло-голубой на синем фоне
|
||||
else Color(0xFF2196F3) // Стандартный Material Blue для входящих
|
||||
}
|
||||
|
||||
val timeColor =
|
||||
remember(message.isOutgoing, isDarkTheme) {
|
||||
if (message.isOutgoing) Color.White.copy(alpha = 0.7f)
|
||||
@@ -606,7 +613,8 @@ fun MessageBubble(
|
||||
AppleEmojiText(
|
||||
text = message.text,
|
||||
color = textColor,
|
||||
fontSize = 16.sp
|
||||
fontSize = 16.sp,
|
||||
linkColor = linkColor
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
@@ -652,7 +660,8 @@ fun MessageBubble(
|
||||
AppleEmojiText(
|
||||
text = message.text,
|
||||
color = textColor,
|
||||
fontSize = 17.sp
|
||||
fontSize = 17.sp,
|
||||
linkColor = linkColor
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
@@ -689,7 +698,8 @@ fun MessageBubble(
|
||||
AppleEmojiText(
|
||||
text = message.text,
|
||||
color = textColor,
|
||||
fontSize = 17.sp
|
||||
fontSize = 17.sp,
|
||||
linkColor = linkColor
|
||||
)
|
||||
},
|
||||
timeContent = {
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
package com.rosetta.messenger.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.text.Editable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.TextPaint
|
||||
import android.text.TextWatcher
|
||||
import android.text.method.LinkMovementMethod
|
||||
import android.text.style.ClickableSpan
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.ImageSpan
|
||||
import android.util.AttributeSet
|
||||
import android.util.LruCache
|
||||
import android.util.Patterns
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
@@ -305,6 +313,7 @@ fun AppleEmojiTextField(
|
||||
|
||||
/**
|
||||
* TextView с Apple эмодзи (для отображения, не редактирования)
|
||||
* 🔥 Поддержка кликабельных ссылок (URL, email, телефоны)
|
||||
*/
|
||||
@Composable
|
||||
fun AppleEmojiText(
|
||||
@@ -314,7 +323,9 @@ fun AppleEmojiText(
|
||||
fontSize: androidx.compose.ui.unit.TextUnit = androidx.compose.ui.unit.TextUnit.Unspecified,
|
||||
fontWeight: androidx.compose.ui.text.font.FontWeight? = null,
|
||||
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
|
||||
enableLinks: Boolean = true // 🔥 Включить кликабельные ссылки
|
||||
) {
|
||||
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
|
||||
else fontSize.value
|
||||
@@ -344,6 +355,11 @@ fun AppleEmojiText(
|
||||
if (overflow != null) {
|
||||
ellipsize = overflow
|
||||
}
|
||||
// 🔥 Включаем кликабельные ссылки
|
||||
if (enableLinks) {
|
||||
setLinkColor(linkColor.toArgb())
|
||||
enableClickableLinks(true)
|
||||
}
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
@@ -355,6 +371,11 @@ fun AppleEmojiText(
|
||||
if (overflow != null) {
|
||||
view.ellipsize = overflow
|
||||
}
|
||||
// 🔥 Обновляем настройки ссылок
|
||||
if (enableLinks) {
|
||||
view.setLinkColor(linkColor.toArgb())
|
||||
view.enableClickableLinks(true)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
@@ -365,6 +386,7 @@ fun AppleEmojiText(
|
||||
* 🔥 Поддерживает:
|
||||
* - Реальные Unicode эмодзи (😀)
|
||||
* - Текстовый формат :emoji_1f600: (как в React Native версии)
|
||||
* - Кликабельные ссылки (URL, email, телефоны)
|
||||
*/
|
||||
class AppleEmojiTextView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
@@ -377,13 +399,48 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
// 🔥 Паттерн для :emoji_XXXX: формата (из React Native)
|
||||
private val EMOJI_CODE_PATTERN = Pattern.compile(":emoji_([a-fA-F0-9_-]+):")
|
||||
private val bitmapCache = LruCache<String, Bitmap>(100)
|
||||
|
||||
// 🔥 Паттерны для ссылок
|
||||
private val URL_PATTERN = Pattern.compile(
|
||||
"(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)" +
|
||||
"|(www\\.[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)" +
|
||||
"|([\\w\\-]+\\.(com|org|net|ru|io|dev|app|me|co|info|biz|edu|gov|uk|de|fr|jp|cn|br|in|au|ca|it|es|nl|se|no|fi|dk|pl|ua|kz|by)[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]*)"
|
||||
)
|
||||
private val EMAIL_PATTERN = Patterns.EMAIL_ADDRESS
|
||||
private val PHONE_PATTERN = Pattern.compile(
|
||||
"\\+?[0-9][\\s\\-()0-9]{6,}[0-9]"
|
||||
)
|
||||
}
|
||||
|
||||
private var linkColorValue: Int = 0xFF54A9EB.toInt() // Default Telegram blue
|
||||
private var linksEnabled: Boolean = false
|
||||
|
||||
init {
|
||||
// Отключаем лишние отступы шрифта для корректного отображения emoji
|
||||
includeFontPadding = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Установить цвет для ссылок
|
||||
*/
|
||||
fun setLinkColor(color: Int) {
|
||||
linkColorValue = color
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Включить/выключить кликабельные ссылки
|
||||
*/
|
||||
fun enableClickableLinks(enable: Boolean) {
|
||||
linksEnabled = enable
|
||||
if (enable) {
|
||||
movementMethod = LinkMovementMethod.getInstance()
|
||||
// Убираем highlight при клике
|
||||
highlightColor = android.graphics.Color.TRANSPARENT
|
||||
} else {
|
||||
movementMethod = null
|
||||
}
|
||||
}
|
||||
|
||||
fun setTextWithEmojis(text: String) {
|
||||
// 🔥 Сначала заменяем :emoji_XXXX: на PNG изображения
|
||||
val spannable = SpannableStringBuilder(text)
|
||||
@@ -449,8 +506,94 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи
|
||||
if (linksEnabled) {
|
||||
addClickableLinks(spannable)
|
||||
}
|
||||
|
||||
setText(spannable)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Добавляет кликабельные ссылки (URL, email, телефоны) в spannable
|
||||
*/
|
||||
private enum class LinkType { URL, EMAIL, PHONE }
|
||||
|
||||
private fun addClickableLinks(spannable: SpannableStringBuilder) {
|
||||
val textStr = spannable.toString()
|
||||
|
||||
// Собираем все найденные ссылки
|
||||
data class LinkMatch(val start: Int, val end: Int, val url: String, val type: LinkType)
|
||||
val linkMatches = mutableListOf<LinkMatch>()
|
||||
|
||||
// 1. Ищем URL
|
||||
val urlMatcher = URL_PATTERN.matcher(textStr)
|
||||
while (urlMatcher.find()) {
|
||||
var url = urlMatcher.group()
|
||||
// Добавляем https:// если нет протокола
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||
url = "https://$url"
|
||||
}
|
||||
linkMatches.add(LinkMatch(urlMatcher.start(), urlMatcher.end(), url, LinkType.URL))
|
||||
}
|
||||
|
||||
// 2. Ищем Email
|
||||
val emailMatcher = EMAIL_PATTERN.matcher(textStr)
|
||||
while (emailMatcher.find()) {
|
||||
val email = emailMatcher.group()
|
||||
// Проверяем что не перекрывается с URL
|
||||
val overlaps = linkMatches.any {
|
||||
(emailMatcher.start() >= it.start && emailMatcher.start() < it.end) ||
|
||||
(emailMatcher.end() > it.start && emailMatcher.end() <= it.end)
|
||||
}
|
||||
if (!overlaps) {
|
||||
linkMatches.add(LinkMatch(emailMatcher.start(), emailMatcher.end(), "mailto:$email", LinkType.EMAIL))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Ищем телефоны
|
||||
val phoneMatcher = PHONE_PATTERN.matcher(textStr)
|
||||
while (phoneMatcher.find()) {
|
||||
val phone = phoneMatcher.group().replace("[\\s\\-()]".toRegex(), "")
|
||||
// Проверяем что не перекрывается с другими ссылками
|
||||
val overlaps = linkMatches.any {
|
||||
(phoneMatcher.start() >= it.start && phoneMatcher.start() < it.end) ||
|
||||
(phoneMatcher.end() > it.start && phoneMatcher.end() <= it.end)
|
||||
}
|
||||
if (!overlaps && phone.length >= 7) {
|
||||
linkMatches.add(LinkMatch(phoneMatcher.start(), phoneMatcher.end(), "tel:$phone", LinkType.PHONE))
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Применяем ClickableSpan для каждой ссылки
|
||||
for (link in linkMatches) {
|
||||
val clickableSpan = object : ClickableSpan() {
|
||||
override fun onClick(widget: View) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link.url))
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
// Если не удалось открыть ссылку, игнорируем
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateDrawState(ds: TextPaint) {
|
||||
super.updateDrawState(ds)
|
||||
ds.color = linkColorValue
|
||||
ds.isUnderlineText = false // Убираем подчёркивание (Telegram-style)
|
||||
}
|
||||
}
|
||||
|
||||
spannable.setSpan(
|
||||
clickableSpan,
|
||||
link.start,
|
||||
link.end,
|
||||
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadEmojiBitmap(unified: String): Bitmap? {
|
||||
bitmapCache.get(unified)?.let { return it }
|
||||
|
||||
@@ -158,6 +158,7 @@ private fun computeAvatarState(
|
||||
avatarSizeMinPx: Float, // Into notch size (24dp or notch width)
|
||||
hasAvatar: Boolean,
|
||||
// Notch info
|
||||
notchCenterX: Float, // X position of front camera/notch
|
||||
notchCenterY: Float,
|
||||
notchRadiusPx: Float,
|
||||
// Telegram thresholds in pixels
|
||||
@@ -211,9 +212,20 @@ private fun computeAvatarState(
|
||||
val isNear = radius <= dp32
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CENTER X - always screen center
|
||||
// CENTER X - animate towards notch/camera position when collapsing
|
||||
// Normal: screen center, Collapsed: notch center (front camera)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
val centerX = screenWidthPx / 2f
|
||||
val startX = screenWidthPx / 2f // Normal position = screen center
|
||||
val endX = notchCenterX // Target = front camera position
|
||||
|
||||
val centerX: Float = when {
|
||||
// Pull-down expansion - stay at screen center
|
||||
hasAvatar && expansionProgress > 0f -> screenWidthPx / 2f
|
||||
// Collapsing - animate X towards notch/camera
|
||||
collapseProgress > 0f -> lerpFloat(endX, startX, diff)
|
||||
// Normal state - screen center
|
||||
else -> screenWidthPx / 2f
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CENTER Y - Telegram: avatarY = lerp(endY, startY, diff)
|
||||
@@ -380,7 +392,7 @@ fun ProfileMetaballOverlay(
|
||||
// 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
|
||||
// derivedStateOf автоматически отслеживает их как зависимости внутри лямбды
|
||||
// Только стабильные параметры (размеры экрана, notch info) как ключи remember
|
||||
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterY, notchRadiusPx) {
|
||||
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) {
|
||||
derivedStateOf {
|
||||
computeAvatarState(
|
||||
collapseProgress = collapseProgress,
|
||||
@@ -391,6 +403,7 @@ fun ProfileMetaballOverlay(
|
||||
avatarSizeExpandedPx = avatarSizeExpandedPx,
|
||||
avatarSizeMinPx = avatarSizeMinPx,
|
||||
hasAvatar = hasAvatar,
|
||||
notchCenterX = notchCenterX,
|
||||
notchCenterY = notchCenterY,
|
||||
notchRadiusPx = notchRadiusPx,
|
||||
dp40 = dp40,
|
||||
@@ -649,6 +662,7 @@ fun ProfileMetaballOverlayCompat(
|
||||
val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() }
|
||||
|
||||
// Fallback notch values for compat mode
|
||||
val notchCenterX = screenWidthPx / 2f // Center of screen for compat mode
|
||||
val notchCenterY = statusBarHeightPx / 2f
|
||||
val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() }
|
||||
|
||||
@@ -661,7 +675,7 @@ fun ProfileMetaballOverlayCompat(
|
||||
|
||||
// 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
|
||||
// derivedStateOf автоматически отслеживает их как зависимости
|
||||
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx) {
|
||||
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX) {
|
||||
derivedStateOf {
|
||||
computeAvatarState(
|
||||
collapseProgress = collapseProgress,
|
||||
@@ -672,6 +686,7 @@ fun ProfileMetaballOverlayCompat(
|
||||
avatarSizeExpandedPx = avatarSizeExpandedPx,
|
||||
avatarSizeMinPx = avatarSizeMinPx,
|
||||
hasAvatar = hasAvatar,
|
||||
notchCenterX = notchCenterX,
|
||||
notchCenterY = notchCenterY,
|
||||
notchRadiusPx = notchRadiusPx,
|
||||
dp40 = dp40,
|
||||
|
||||
Reference in New Issue
Block a user