diff --git a/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt new file mode 100644 index 00000000..4a7d1dd0 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/LinkPreviewPill.kt @@ -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 { + val urls = mutableListOf() + 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) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 57e4db22..1dbbfe55 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -102,27 +102,54 @@ fun MessageItem( val colorScheme = MaterialTheme.colorScheme val timeFormatter = remember { SimpleDateFormat("HH:mm:ss", Locale.getDefault()) } - Row( + Column( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top + verticalArrangement = Arrangement.spacedBy(0.dp) ) { - // Create a custom layout that combines selectable text with clickable nickname areas - MessageTextWithClickableNicknames( - message = message, - currentUserNickname = currentUserNickname, - meshService = meshService, - colorScheme = colorScheme, - timeFormatter = timeFormatter, - onNicknameClick = onNicknameClick, - onNicknameLongPress = onNicknameLongPress, - modifier = Modifier.weight(1f) - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + // Create a custom layout that combines selectable text with clickable nickname areas + MessageTextWithClickableNicknames( + message = message, + currentUserNickname = currentUserNickname, + 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 - if (message.isPrivate && message.sender == currentUserNickname) { - message.deliveryStatus?.let { status -> - DeliveryStatusIcon(status = status) + // 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() + ) + } + } } } }