mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-24 23:45:19 +00:00
@@ -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,27 +102,54 @@ fun MessageItem(
|
|||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) }
|
||||||
|
|
||||||
Row(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.spacedBy(0.dp)
|
||||||
verticalAlignment = Alignment.Top
|
|
||||||
) {
|
) {
|
||||||
// Create a custom layout that combines selectable text with clickable nickname areas
|
Row(
|
||||||
MessageTextWithClickableNicknames(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
message = message,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
currentUserNickname = currentUserNickname,
|
verticalAlignment = Alignment.Top
|
||||||
meshService = meshService,
|
) {
|
||||||
colorScheme = colorScheme,
|
// Create a custom layout that combines selectable text with clickable nickname areas
|
||||||
timeFormatter = timeFormatter,
|
MessageTextWithClickableNicknames(
|
||||||
onNicknameClick = onNicknameClick,
|
message = message,
|
||||||
onNicknameLongPress = onNicknameLongPress,
|
currentUserNickname = currentUserNickname,
|
||||||
modifier = Modifier.weight(1f)
|
meshService = meshService,
|
||||||
)
|
colorScheme = colorScheme,
|
||||||
|
timeFormatter = timeFormatter,
|
||||||
|
onNicknameClick = onNicknameClick,
|
||||||
|
onNicknameLongPress = onNicknameLongPress,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delivery status for private messages
|
||||||
|
if (message.isPrivate && message.sender == currentUserNickname) {
|
||||||
|
message.deliveryStatus?.let { status ->
|
||||||
|
DeliveryStatusIcon(status = status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delivery status for private messages
|
// Link preview pills for URLs in message content
|
||||||
if (message.isPrivate && message.sender == currentUserNickname) {
|
if (message.sender != "system") {
|
||||||
message.deliveryStatus?.let { status ->
|
val urls = URLDetector.extractUrls(message.content)
|
||||||
DeliveryStatusIcon(status = status)
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user