Link preview (#286)

* link preview

* link previews
This commit is contained in:
callebtc
2025-08-23 00:38:33 +02:00
committed by GitHub
parent 49c42ba169
commit 65afdfb92e
2 changed files with 225 additions and 18 deletions
@@ -0,0 +1,180 @@
package com.bitchat.android.ui
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Link
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.net.URL
/**
* LinkPreviewPill - A compact link preview component that matches the iOS design
*
* This component replicates the iOS link preview pill functionality:
* - Shows a clickable pill with link icon, title, and host
* - No preview image fetching - just styled link display
* - Opens link in external browser when clicked
* - Uses iOS-style green colors and monospaced fonts
*/
@Composable
fun LinkPreviewPill(
url: String,
title: String? = null,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
// iOS-style colors
val textColor = if (isDark) Color.Green else Color(red = 0f, green = 0.5f, blue = 0f)
val backgroundColor = if (isDark) Color.Gray.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.08f)
val borderColor = textColor.copy(alpha = 0.3f)
// Parse URL for host extraction
val parsedUrl = try {
URL(url)
} catch (e: Exception) {
null
}
val displayTitle = title ?: parsedUrl?.host ?: "Link"
val displayHost = parsedUrl?.host ?: url
Surface(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(backgroundColor)
.border(1.dp, borderColor, RoundedCornerShape(10.dp))
.clickable {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
} catch (e: Exception) {
// Silently fail if URL can't be opened
}
},
color = Color.Transparent
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(10.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Link icon (matches iOS placeholder)
Surface(
modifier = Modifier.size(60.dp),
shape = RoundedCornerShape(8.dp),
color = Color.Blue.copy(alpha = 0.1f)
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.Link,
contentDescription = "Link",
modifier = Modifier.size(24.dp),
tint = Color.Blue
)
}
}
// Text content
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
// Title - matches iOS styling
Text(
text = displayTitle,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
// Host - matches iOS styling
Text(
text = displayHost,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = textColor.copy(alpha = 0.6f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
/**
* URL detection utility that extracts URLs from text content
* Matches the iOS extractURLs() extension functionality
*/
object URLDetector {
// Regex pattern for URL detection (matches common URL patterns)
private val urlRegex = Regex(
"""(?i)\b(?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^\s`!()\[\]{};:'".,<>?«»""''])""",
RegexOption.IGNORE_CASE
)
data class UrlMatch(
val url: String,
val range: IntRange
)
/**
* Extract URLs from text content
* Returns list of URL matches with their positions in the text
*/
fun extractUrls(text: String): List<UrlMatch> {
val urls = mutableListOf<UrlMatch>()
val matches = urlRegex.findAll(text)
for (match in matches) {
var url = match.value
val start = match.range.first
val end = match.range.last
// Add protocol if missing
if (!url.startsWith("http://") && !url.startsWith("https://")) {
url = "https://$url"
}
urls.add(UrlMatch(url, start..end))
}
return urls
}
/**
* Check if a string contains any URLs
*/
fun containsUrls(text: String): Boolean {
return urlRegex.containsMatchIn(text)
}
}
@@ -102,6 +102,10 @@ fun MessageItem(
val colorScheme = MaterialTheme.colorScheme
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -126,6 +130,29 @@ 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()
)
}
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)