mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 22:25:19 +00:00
render links normally
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user