fix: enable clickable links in AppleEmojiText for URLs, emails, and phone numbers

This commit is contained in:
k1ngsterr1
2026-02-04 23:21:22 +05:00
parent 612cfe1a6b
commit 54c5f015bb
4 changed files with 182 additions and 9 deletions

View File

@@ -54,7 +54,12 @@ android {
kotlinOptions { jvmTarget = "11" }
buildFeatures { compose = true }
composeOptions { kotlinCompilerExtensionVersion = "1.5.4" }
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
packaging {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
jniLibs {
useLegacyPackaging = true
}
}
}
dependencies {

View File

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

View File

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

View File

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