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" } kotlinOptions { jvmTarget = "11" }
buildFeatures { compose = true } buildFeatures { compose = true }
composeOptions { kotlinCompilerExtensionVersion = "1.5.4" } 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 { dependencies {

View File

@@ -290,6 +290,13 @@ fun MessageBubble(
else if (isDarkTheme) Color.White else Color(0xFF000000) 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 = val timeColor =
remember(message.isOutgoing, isDarkTheme) { remember(message.isOutgoing, isDarkTheme) {
if (message.isOutgoing) Color.White.copy(alpha = 0.7f) if (message.isOutgoing) Color.White.copy(alpha = 0.7f)
@@ -606,7 +613,8 @@ fun MessageBubble(
AppleEmojiText( AppleEmojiText(
text = message.text, text = message.text,
color = textColor, color = textColor,
fontSize = 16.sp fontSize = 16.sp,
linkColor = linkColor
) )
}, },
timeContent = { timeContent = {
@@ -652,7 +660,8 @@ fun MessageBubble(
AppleEmojiText( AppleEmojiText(
text = message.text, text = message.text,
color = textColor, color = textColor,
fontSize = 17.sp fontSize = 17.sp,
linkColor = linkColor
) )
}, },
timeContent = { timeContent = {
@@ -689,7 +698,8 @@ fun MessageBubble(
AppleEmojiText( AppleEmojiText(
text = message.text, text = message.text,
color = textColor, color = textColor,
fontSize = 17.sp fontSize = 17.sp,
linkColor = linkColor
) )
}, },
timeContent = { timeContent = {

View File

@@ -1,16 +1,24 @@
package com.rosetta.messenger.ui.components package com.rosetta.messenger.ui.components
import android.content.Context import android.content.Context
import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.text.Editable import android.text.Editable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.TextPaint
import android.text.TextWatcher 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.text.style.ImageSpan
import android.util.AttributeSet import android.util.AttributeSet
import android.util.LruCache import android.util.LruCache
import android.util.Patterns
import android.view.Gravity import android.view.Gravity
import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.widget.EditText import android.widget.EditText
import android.widget.FrameLayout import android.widget.FrameLayout
@@ -305,6 +313,7 @@ fun AppleEmojiTextField(
/** /**
* TextView с Apple эмодзи (для отображения, не редактирования) * TextView с Apple эмодзи (для отображения, не редактирования)
* 🔥 Поддержка кликабельных ссылок (URL, email, телефоны)
*/ */
@Composable @Composable
fun AppleEmojiText( fun AppleEmojiText(
@@ -314,7 +323,9 @@ fun AppleEmojiText(
fontSize: androidx.compose.ui.unit.TextUnit = androidx.compose.ui.unit.TextUnit.Unspecified, fontSize: androidx.compose.ui.unit.TextUnit = androidx.compose.ui.unit.TextUnit.Unspecified,
fontWeight: androidx.compose.ui.text.font.FontWeight? = null, fontWeight: androidx.compose.ui.text.font.FontWeight? = null,
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
enableLinks: Boolean = true // 🔥 Включить кликабельные ссылки
) { ) {
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
else fontSize.value else fontSize.value
@@ -344,6 +355,11 @@ fun AppleEmojiText(
if (overflow != null) { if (overflow != null) {
ellipsize = overflow ellipsize = overflow
} }
// 🔥 Включаем кликабельные ссылки
if (enableLinks) {
setLinkColor(linkColor.toArgb())
enableClickableLinks(true)
}
} }
}, },
update = { view -> update = { view ->
@@ -355,6 +371,11 @@ fun AppleEmojiText(
if (overflow != null) { if (overflow != null) {
view.ellipsize = overflow view.ellipsize = overflow
} }
// 🔥 Обновляем настройки ссылок
if (enableLinks) {
view.setLinkColor(linkColor.toArgb())
view.enableClickableLinks(true)
}
}, },
modifier = modifier modifier = modifier
) )
@@ -365,6 +386,7 @@ fun AppleEmojiText(
* 🔥 Поддерживает: * 🔥 Поддерживает:
* - Реальные Unicode эмодзи (😀) * - Реальные Unicode эмодзи (😀)
* - Текстовый формат :emoji_1f600: (как в React Native версии) * - Текстовый формат :emoji_1f600: (как в React Native версии)
* - Кликабельные ссылки (URL, email, телефоны)
*/ */
class AppleEmojiTextView @JvmOverloads constructor( class AppleEmojiTextView @JvmOverloads constructor(
context: Context, context: Context,
@@ -377,13 +399,48 @@ class AppleEmojiTextView @JvmOverloads constructor(
// 🔥 Паттерн для :emoji_XXXX: формата (из React Native) // 🔥 Паттерн для :emoji_XXXX: формата (из React Native)
private val EMOJI_CODE_PATTERN = Pattern.compile(":emoji_([a-fA-F0-9_-]+):") private val EMOJI_CODE_PATTERN = Pattern.compile(":emoji_([a-fA-F0-9_-]+):")
private val bitmapCache = LruCache<String, Bitmap>(100) 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 { init {
// Отключаем лишние отступы шрифта для корректного отображения emoji // Отключаем лишние отступы шрифта для корректного отображения emoji
includeFontPadding = false 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) { fun setTextWithEmojis(text: String) {
// 🔥 Сначала заменяем :emoji_XXXX: на PNG изображения // 🔥 Сначала заменяем :emoji_XXXX: на PNG изображения
val spannable = SpannableStringBuilder(text) val spannable = SpannableStringBuilder(text)
@@ -449,9 +506,95 @@ class AppleEmojiTextView @JvmOverloads constructor(
} }
} }
// 🔥 5. Добавляем кликабельные ссылки после обработки эмодзи
if (linksEnabled) {
addClickableLinks(spannable)
}
setText(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? { private fun loadEmojiBitmap(unified: String): Bitmap? {
bitmapCache.get(unified)?.let { return it } bitmapCache.get(unified)?.let { return it }

View File

@@ -158,6 +158,7 @@ private fun computeAvatarState(
avatarSizeMinPx: Float, // Into notch size (24dp or notch width) avatarSizeMinPx: Float, // Into notch size (24dp or notch width)
hasAvatar: Boolean, hasAvatar: Boolean,
// Notch info // Notch info
notchCenterX: Float, // X position of front camera/notch
notchCenterY: Float, notchCenterY: Float,
notchRadiusPx: Float, notchRadiusPx: Float,
// Telegram thresholds in pixels // Telegram thresholds in pixels
@@ -211,9 +212,20 @@ private fun computeAvatarState(
val isNear = radius <= dp32 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) // CENTER Y - Telegram: avatarY = lerp(endY, startY, diff)
@@ -380,7 +392,7 @@ fun ProfileMetaballOverlay(
// 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember // 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
// derivedStateOf автоматически отслеживает их как зависимости внутри лямбды // derivedStateOf автоматически отслеживает их как зависимости внутри лямбды
// Только стабильные параметры (размеры экрана, notch info) как ключи remember // Только стабильные параметры (размеры экрана, notch info) как ключи remember
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterY, notchRadiusPx) { val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) {
derivedStateOf { derivedStateOf {
computeAvatarState( computeAvatarState(
collapseProgress = collapseProgress, collapseProgress = collapseProgress,
@@ -391,6 +403,7 @@ fun ProfileMetaballOverlay(
avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeExpandedPx = avatarSizeExpandedPx,
avatarSizeMinPx = avatarSizeMinPx, avatarSizeMinPx = avatarSizeMinPx,
hasAvatar = hasAvatar, hasAvatar = hasAvatar,
notchCenterX = notchCenterX,
notchCenterY = notchCenterY, notchCenterY = notchCenterY,
notchRadiusPx = notchRadiusPx, notchRadiusPx = notchRadiusPx,
dp40 = dp40, dp40 = dp40,
@@ -649,6 +662,7 @@ fun ProfileMetaballOverlayCompat(
val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() } val avatarSizeMinPx = with(density) { ProfileMetaballConstants.AVATAR_SIZE_MIN.toPx() }
// Fallback notch values for compat mode // Fallback notch values for compat mode
val notchCenterX = screenWidthPx / 2f // Center of screen for compat mode
val notchCenterY = statusBarHeightPx / 2f val notchCenterY = statusBarHeightPx / 2f
val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() }
@@ -661,7 +675,7 @@ fun ProfileMetaballOverlayCompat(
// 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember // 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
// derivedStateOf автоматически отслеживает их как зависимости // derivedStateOf автоматически отслеживает их как зависимости
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx) { val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX) {
derivedStateOf { derivedStateOf {
computeAvatarState( computeAvatarState(
collapseProgress = collapseProgress, collapseProgress = collapseProgress,
@@ -672,6 +686,7 @@ fun ProfileMetaballOverlayCompat(
avatarSizeExpandedPx = avatarSizeExpandedPx, avatarSizeExpandedPx = avatarSizeExpandedPx,
avatarSizeMinPx = avatarSizeMinPx, avatarSizeMinPx = avatarSizeMinPx,
hasAvatar = hasAvatar, hasAvatar = hasAvatar,
notchCenterX = notchCenterX,
notchCenterY = notchCenterY, notchCenterY = notchCenterY,
notchRadiusPx = notchRadiusPx, notchRadiusPx = notchRadiusPx,
dp40 = dp40, dp40 = dp40,