From 2ec31414310fc7070b5beadcef4326e74e125026 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Fri, 29 Aug 2025 00:40:25 +0200 Subject: [PATCH] render links normally (#331) --- .../com/bitchat/android/ui/ChatUIUtils.kt | 37 +++- .../bitchat/android/ui/MessageComponents.kt | 168 ++++++++---------- .../android/ui/MessageSpecialParser.kt | 57 ++++++ 3 files changed, 162 insertions(+), 100 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt index 4e98a4e0..bdb2fa1f 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatUIUtils.kt @@ -270,18 +270,29 @@ private fun appendIOSFormattedContent( } } + // Add URL matches (http/https/www/bare domains). Exclude overlaps with mentions. + val urlMatches = MessageSpecialParser.findUrls(content) + for (um in urlMatches) { + val range = um.start until um.endExclusive + if (!overlapsMention(range)) { + allMatches.add(range to "url") + } + } + // Remove generic hashtag matches that overlap with detected geohash ranges to avoid duplicate rendering fun rangesOverlap(a: IntRange, b: IntRange): Boolean { return a.first < b.last && a.last > b.first } + val urlRanges = allMatches.filter { it.second == "url" }.map { it.first } val geoRanges = allMatches.filter { it.second == "geohash" }.map { it.first } - if (geoRanges.isNotEmpty()) { + if (geoRanges.isNotEmpty() || urlRanges.isNotEmpty()) { val iterator = allMatches.listIterator() while (iterator.hasNext()) { val (range, type) = iterator.next() - if (type == "hashtag" && geoRanges.any { rangesOverlap(range, it) }) { - iterator.remove() - } + // Remove generic hashtags that overlap with geohashes, and geohashes that overlap with URLs + val overlapsGeo = geoRanges.any { rangesOverlap(range, it) } + val overlapsUrl = urlRanges.any { rangesOverlap(range, it) } + if ((type == "hashtag" && overlapsGeo) || (type == "geohash" && overlapsUrl)) iterator.remove() } } @@ -389,6 +400,24 @@ private fun appendIOSFormattedContent( end = end ) builder.pop() + } else if (type == "url") { + // Style URL in blue, underlined, and add click annotation with the raw text + builder.pushStyle(SpanStyle( + color = Color(0xFF007AFF), + fontSize = BASE_FONT_SIZE.sp, + fontWeight = if (isSelf) FontWeight.Bold else FontWeight.SemiBold, + textDecoration = TextDecoration.Underline + )) + val start = builder.length + builder.append(matchText) + val end = builder.length + builder.addStringAnnotation( + tag = "url_click", + annotation = matchText, + start = start, + end = end + ) + builder.pop() } else { // Fallback: treat as normal text builder.pushStyle(SpanStyle( diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index a6cbe8e9..a27bbdad 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -1,7 +1,6 @@ package com.bitchat.android.ui import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -24,6 +23,8 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import android.content.Intent +import android.net.Uri import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.DeliveryStatus import com.bitchat.android.mesh.BluetoothMeshService @@ -149,27 +150,7 @@ fun MessageItem( } } - // Link preview pills for URLs in message content - if (message.sender != "system") { - val urls = URLDetector.extractUrls(message.content) - if (urls.isNotEmpty()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 3.dp, start = 1.dp, end = 1.dp), - verticalArrangement = Arrangement.spacedBy(3.dp) - ) { - // Show up to 3 URL previews (matches iOS behavior) - urls.take(3).forEach { urlMatch -> - LinkPreviewPill( - url = urlMatch.url, - title = null, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - } + // Link previews removed; links are now highlighted inline and clickable within the message text } } @@ -198,17 +179,18 @@ private fun MessageTextWithClickableNicknames( message.sender == currentUserNickname || message.sender.startsWith("$currentUserNickname#") - if (!isSelf && (onNicknameClick != null || onMessageLongPress != null)) { - val haptic = LocalHapticFeedback.current - val context = LocalContext.current - var textLayoutResult by remember { mutableStateOf(null) } - Text( - text = annotatedText, - modifier = modifier.pointerInput(message) { - detectTapGestures( - onTap = { position -> - val layout = textLayoutResult ?: return@detectTapGestures - val offset = layout.getOffsetForPosition(position) + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + var textLayoutResult by remember { mutableStateOf(null) } + Text( + text = annotatedText, + modifier = modifier.pointerInput(message) { + detectTapGestures( + onTap = { position -> + val layout = textLayoutResult ?: return@detectTapGestures + val offset = layout.getOffsetForPosition(position) + // Nickname click only when not self + if (!isSelf && onNicknameClick != null) { val nicknameAnnotations = annotatedText.getStringAnnotations( tag = "nickname_click", start = offset, @@ -217,74 +199,68 @@ private fun MessageTextWithClickableNicknames( if (nicknameAnnotations.isNotEmpty()) { val nickname = nicknameAnnotations.first().item haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - onNicknameClick?.invoke(nickname) + onNicknameClick.invoke(nickname) return@detectTapGestures } - // Handle geohash click to teleport - val geohashAnnotations = annotatedText.getStringAnnotations( - tag = "geohash_click", - start = offset, - end = offset - ) - if (geohashAnnotations.isNotEmpty()) { - val geohash = geohashAnnotations.first().item - try { - val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance( - context - ) - // Select appropriate precision based on length - val level = when (geohash.length) { - in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION - in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE - 5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY - 6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD - else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK - } - val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase()) - locationManager.setTeleported(true) - locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel)) - } catch (_: Exception) { } - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - } - }, - onLongPress = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onMessageLongPress?.invoke(message) } - ) - }, - fontFamily = FontFamily.Monospace, - softWrap = true, - overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle( - color = colorScheme.onSurface - ), - onTextLayout = { result -> textLayoutResult = result } - ) - } else { - // Use regular text with message long press support for own messages - val haptic = LocalHapticFeedback.current - Text( - text = annotatedText, - modifier = if (onMessageLongPress != null) { - modifier.combinedClickable( - onClick = { /* No action for own messages */ }, - onLongClick = { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) - onMessageLongPress.invoke(message) + // Geohash teleport (all messages) + val geohashAnnotations = annotatedText.getStringAnnotations( + tag = "geohash_click", + start = offset, + end = offset + ) + if (geohashAnnotations.isNotEmpty()) { + val geohash = geohashAnnotations.first().item + try { + val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance( + context + ) + val level = when (geohash.length) { + in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION + in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE + 5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY + 6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD + else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK + } + val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase()) + locationManager.setTeleported(true) + locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel)) + } catch (_: Exception) { } + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + return@detectTapGestures } - ) - } else { - modifier - }, - fontFamily = FontFamily.Monospace, - softWrap = true, - overflow = TextOverflow.Visible, - style = androidx.compose.ui.text.TextStyle( - color = colorScheme.onSurface + // URL open (all messages) + val urlAnnotations = annotatedText.getStringAnnotations( + tag = "url_click", + start = offset, + end = offset + ) + if (urlAnnotations.isNotEmpty()) { + val raw = urlAnnotations.first().item + val resolved = if (raw.startsWith("http://", ignoreCase = true) || raw.startsWith("https://", ignoreCase = true)) raw else "https://$raw" + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(resolved)) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } catch (_: Exception) { } + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + return@detectTapGestures + } + }, + onLongPress = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongPress?.invoke(message) + } ) - ) - } + }, + fontFamily = FontFamily.Monospace, + softWrap = true, + overflow = TextOverflow.Visible, + style = androidx.compose.ui.text.TextStyle( + color = colorScheme.onSurface + ), + onTextLayout = { result -> textLayoutResult = result } + ) } @Composable diff --git a/app/src/main/java/com/bitchat/android/ui/MessageSpecialParser.kt b/app/src/main/java/com/bitchat/android/ui/MessageSpecialParser.kt index f364ba30..8e8dec96 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageSpecialParser.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageSpecialParser.kt @@ -1,6 +1,7 @@ package com.bitchat.android.ui import androidx.compose.ui.text.AnnotatedString +import android.util.Patterns /** * Utilities for parsing special tokens in chat messages (geohashes, etc.). @@ -11,6 +12,7 @@ object MessageSpecialParser { private val standaloneGeohashRegex = Regex("(^|[^A-Za-z0-9_#])#([0-9bcdefghjkmnpqrstuvwxyz]{2,})($|[^A-Za-z0-9_])", RegexOption.IGNORE_CASE) data class GeohashMatch(val start: Int, val endExclusive: Int, val geohash: String) + data class UrlMatch(val start: Int, val endExclusive: Int, val url: String) /** * Finds standalone geohashes within [text]. A match is returned only when @@ -48,6 +50,61 @@ object MessageSpecialParser { } return matches } + + /** + * Detect URLs in text. Supports http(s) schemes, www.* domains, and bare domains with TLDs. + * Trailing punctuation like '.', ',', ')', '!' is trimmed from the match. + */ + fun findUrls(text: String): List { + if (text.isEmpty()) return emptyList() + val results = mutableListOf() + + // 1) Use Android's WEB_URL for robust detection of http(s) and www.* + val webUrl = Patterns.WEB_URL + val matcher = webUrl.matcher(text) + while (matcher.find()) { + var start = matcher.start() + var endExclusive = matcher.end() + var token = text.substring(start, endExclusive) + + // Trim trailing punctuation + while (token.isNotEmpty() && token.last() in setOf('.', ',', ';', ':', '!', '?', '\'', '"')) { + endExclusive -= 1 + token = text.substring(start, endExclusive) + } + results.add(UrlMatch(start, endExclusive, token)) + } + + // 2) Bare-domain fallback for things WEB_URL can miss (e.g., google.com) + val bare = Regex("(? it.start } + if (overlapsExisting) continue + + // Skip emails + if (token.contains('@')) continue + + // Trim trailing punctuation + while (token.isNotEmpty() && token.last() in setOf('.', ',', ';', ':', '!', '?', '\'', '"')) { + endExclusive -= 1 + token = text.substring(start, endExclusive) + } + + // Require at least one dot + if (!token.contains('.')) continue + + results.add(UrlMatch(start, endExclusive, token)) + } + + // Sort by start index + results.sortBy { it.start } + return results + } }