render links normally (#331)

This commit is contained in:
callebtc
2025-08-29 00:40:25 +02:00
committed by GitHub
parent 9c103180cd
commit 2ec3141431
3 changed files with 162 additions and 100 deletions
@@ -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(
@@ -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<TextLayoutResult?>(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<TextLayoutResult?>(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
@@ -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<UrlMatch> {
if (text.isEmpty()) return emptyList()
val results = mutableListOf<UrlMatch>()
// 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("(?<!@)(?<![A-Za-z0-9_-])([A-Za-z0-9-]+\\.[A-Za-z]{2,}(?:/[A-Za-z0-9@:%._+~#=/?&!$'()*,-]*)?)")
for (m in bare.findAll(text)) {
val start = m.range.first
var endExclusive = m.range.last + 1
var token = text.substring(start, endExclusive)
// Exclude if overlaps any already-found match
val overlapsExisting = results.any { start < it.endExclusive && endExclusive > 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
}
}