mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 02:25:21 +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
|
// Remove generic hashtag matches that overlap with detected geohash ranges to avoid duplicate rendering
|
||||||
fun rangesOverlap(a: IntRange, b: IntRange): Boolean {
|
fun rangesOverlap(a: IntRange, b: IntRange): Boolean {
|
||||||
return a.first < b.last && a.last > b.first
|
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 }
|
val geoRanges = allMatches.filter { it.second == "geohash" }.map { it.first }
|
||||||
if (geoRanges.isNotEmpty()) {
|
if (geoRanges.isNotEmpty() || urlRanges.isNotEmpty()) {
|
||||||
val iterator = allMatches.listIterator()
|
val iterator = allMatches.listIterator()
|
||||||
while (iterator.hasNext()) {
|
while (iterator.hasNext()) {
|
||||||
val (range, type) = iterator.next()
|
val (range, type) = iterator.next()
|
||||||
if (type == "hashtag" && geoRanges.any { rangesOverlap(range, it) }) {
|
// Remove generic hashtags that overlap with geohashes, and geohashes that overlap with URLs
|
||||||
iterator.remove()
|
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
|
end = end
|
||||||
)
|
)
|
||||||
builder.pop()
|
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 {
|
} else {
|
||||||
// Fallback: treat as normal text
|
// Fallback: treat as normal text
|
||||||
builder.pushStyle(SpanStyle(
|
builder.pushStyle(SpanStyle(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
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.BitchatMessage
|
||||||
import com.bitchat.android.model.DeliveryStatus
|
import com.bitchat.android.model.DeliveryStatus
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
@@ -149,27 +150,7 @@ fun MessageItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link preview pills for URLs in message content
|
// Link previews removed; links are now highlighted inline and clickable within the message text
|
||||||
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()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,17 +179,18 @@ private fun MessageTextWithClickableNicknames(
|
|||||||
message.sender == currentUserNickname ||
|
message.sender == currentUserNickname ||
|
||||||
message.sender.startsWith("$currentUserNickname#")
|
message.sender.startsWith("$currentUserNickname#")
|
||||||
|
|
||||||
if (!isSelf && (onNicknameClick != null || onMessageLongPress != null)) {
|
val haptic = LocalHapticFeedback.current
|
||||||
val haptic = LocalHapticFeedback.current
|
val context = LocalContext.current
|
||||||
val context = LocalContext.current
|
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||||
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
|
Text(
|
||||||
Text(
|
text = annotatedText,
|
||||||
text = annotatedText,
|
modifier = modifier.pointerInput(message) {
|
||||||
modifier = modifier.pointerInput(message) {
|
detectTapGestures(
|
||||||
detectTapGestures(
|
onTap = { position ->
|
||||||
onTap = { position ->
|
val layout = textLayoutResult ?: return@detectTapGestures
|
||||||
val layout = textLayoutResult ?: return@detectTapGestures
|
val offset = layout.getOffsetForPosition(position)
|
||||||
val offset = layout.getOffsetForPosition(position)
|
// Nickname click only when not self
|
||||||
|
if (!isSelf && onNicknameClick != null) {
|
||||||
val nicknameAnnotations = annotatedText.getStringAnnotations(
|
val nicknameAnnotations = annotatedText.getStringAnnotations(
|
||||||
tag = "nickname_click",
|
tag = "nickname_click",
|
||||||
start = offset,
|
start = offset,
|
||||||
@@ -217,74 +199,68 @@ private fun MessageTextWithClickableNicknames(
|
|||||||
if (nicknameAnnotations.isNotEmpty()) {
|
if (nicknameAnnotations.isNotEmpty()) {
|
||||||
val nickname = nicknameAnnotations.first().item
|
val nickname = nicknameAnnotations.first().item
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
onNicknameClick?.invoke(nickname)
|
onNicknameClick.invoke(nickname)
|
||||||
return@detectTapGestures
|
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)
|
|
||||||
}
|
}
|
||||||
)
|
// Geohash teleport (all messages)
|
||||||
},
|
val geohashAnnotations = annotatedText.getStringAnnotations(
|
||||||
fontFamily = FontFamily.Monospace,
|
tag = "geohash_click",
|
||||||
softWrap = true,
|
start = offset,
|
||||||
overflow = TextOverflow.Visible,
|
end = offset
|
||||||
style = androidx.compose.ui.text.TextStyle(
|
)
|
||||||
color = colorScheme.onSurface
|
if (geohashAnnotations.isNotEmpty()) {
|
||||||
),
|
val geohash = geohashAnnotations.first().item
|
||||||
onTextLayout = { result -> textLayoutResult = result }
|
try {
|
||||||
)
|
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(
|
||||||
} else {
|
context
|
||||||
// Use regular text with message long press support for own messages
|
)
|
||||||
val haptic = LocalHapticFeedback.current
|
val level = when (geohash.length) {
|
||||||
Text(
|
in 0..2 -> com.bitchat.android.geohash.GeohashChannelLevel.REGION
|
||||||
text = annotatedText,
|
in 3..4 -> com.bitchat.android.geohash.GeohashChannelLevel.PROVINCE
|
||||||
modifier = if (onMessageLongPress != null) {
|
5 -> com.bitchat.android.geohash.GeohashChannelLevel.CITY
|
||||||
modifier.combinedClickable(
|
6 -> com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD
|
||||||
onClick = { /* No action for own messages */ },
|
else -> com.bitchat.android.geohash.GeohashChannelLevel.BLOCK
|
||||||
onLongClick = {
|
}
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
val channel = com.bitchat.android.geohash.GeohashChannel(level, geohash.lowercase())
|
||||||
onMessageLongPress.invoke(message)
|
locationManager.setTeleported(true)
|
||||||
|
locationManager.select(com.bitchat.android.geohash.ChannelID.Location(channel))
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
|
||||||
|
return@detectTapGestures
|
||||||
}
|
}
|
||||||
)
|
// URL open (all messages)
|
||||||
} else {
|
val urlAnnotations = annotatedText.getStringAnnotations(
|
||||||
modifier
|
tag = "url_click",
|
||||||
},
|
start = offset,
|
||||||
fontFamily = FontFamily.Monospace,
|
end = offset
|
||||||
softWrap = true,
|
)
|
||||||
overflow = TextOverflow.Visible,
|
if (urlAnnotations.isNotEmpty()) {
|
||||||
style = androidx.compose.ui.text.TextStyle(
|
val raw = urlAnnotations.first().item
|
||||||
color = colorScheme.onSurface
|
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
|
@Composable
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import android.util.Patterns
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Utilities for parsing special tokens in chat messages (geohashes, etc.).
|
* 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)
|
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 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
|
* Finds standalone geohashes within [text]. A match is returned only when
|
||||||
@@ -48,6 +50,61 @@ object MessageSpecialParser {
|
|||||||
}
|
}
|
||||||
return matches
|
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