From eb15e29c8b448cd5bf56c1ada7afba988ed429a8 Mon Sep 17 00:00:00 2001 From: jknapp Date: Wed, 11 Feb 2026 08:00:43 -0800 Subject: [PATCH] Add rich message bubble with inline media support (v1.5.0) Features: - Selectable text in all messages (fixes regression) - Clickable links (auto-detected URLs with underline styling) - Inline images/GIFs (jpg, png, gif, webp auto-displayed) - YouTube embeds (shows thumbnail with play button overlay) - Message parsing separates text from media content - Uses Coil library for efficient image loading Implementation: - New RichMessageBubble component replaces ChatMessageBubble - Parses message content into Text/Image/YouTube parts - ClickableTextWithLinks handles URL detection and tap events - ImageContent displays media with click-to-fullscreen - YouTubeEmbed shows thumbnail and opens in YouTube app - Added Coil dependencies (coil-compose + coil-gif) Version: 1.5.0 (versionCode 41) --- app/build.gradle.kts | 8 +- .../openclaw/alfred/gateway/GatewayClient.kt | 2 +- .../alfred/ui/components/RichMessageBubble.kt | 325 ++++++++++++++++++ .../openclaw/alfred/ui/screens/MainScreen.kt | 2 +- 4 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/openclaw/alfred/ui/components/RichMessageBubble.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f64c6a5..ec912c0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "com.openclaw.alfred" minSdk = 26 targetSdk = 34 - versionCode = 35 - versionName = "1.4.1" + versionCode = 41 + versionName = "1.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -132,6 +132,10 @@ dependencies { // Vosk speech recognition for wake word implementation("com.alphacephei:vosk-android:0.3.47") + // Coil for image loading + implementation("io.coil-kt:coil-compose:2.5.0") + implementation("io.coil-kt:coil-gif:2.5.0") + // Testing testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") diff --git a/app/src/main/java/com/openclaw/alfred/gateway/GatewayClient.kt b/app/src/main/java/com/openclaw/alfred/gateway/GatewayClient.kt index a612c44..f3b1663 100644 --- a/app/src/main/java/com/openclaw/alfred/gateway/GatewayClient.kt +++ b/app/src/main/java/com/openclaw/alfred/gateway/GatewayClient.kt @@ -512,7 +512,7 @@ class GatewayClient( ) val json = gson.toJson(connectMsg) - Log.d(TAG, ">>> Sending connect request: $json") + Log.d(TAG, ">>> Sending connect request (sessionKey will be in chat.send): $json") val sent = webSocket?.send(json) Log.d(TAG, "Send result: $sent") } diff --git a/app/src/main/java/com/openclaw/alfred/ui/components/RichMessageBubble.kt b/app/src/main/java/com/openclaw/alfred/ui/components/RichMessageBubble.kt new file mode 100644 index 0000000..66fc931 --- /dev/null +++ b/app/src/main/java/com/openclaw/alfred/ui/components/RichMessageBubble.kt @@ -0,0 +1,325 @@ +package com.openclaw.alfred.ui.components + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import com.openclaw.alfred.ui.screens.ChatMessage + +/** + * Enhanced message bubble with support for: + * - Selectable text + * - Clickable links + * - Inline images/GIFs + * - YouTube embeds (as clickable thumbnails) + */ +@Composable +fun RichMessageBubble(message: ChatMessage) { + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + + val alignment = if (message.sender == "You") Alignment.End else Alignment.Start + val color = when { + message.isSystem -> MaterialTheme.colorScheme.surfaceVariant + message.sender == "You" -> MaterialTheme.colorScheme.primaryContainer + else -> MaterialTheme.colorScheme.secondaryContainer + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = alignment + ) { + Card( + colors = CardDefaults.cardColors(containerColor = color) + ) { + Column( + modifier = Modifier.padding(12.dp) + ) { + // Sender name + if (message.sender != "You") { + Text( + text = message.sender, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + } + + // Parse message content + val contentParts = parseMessageContent(message.text) + + contentParts.forEach { part -> + when (part) { + is ContentPart.Text -> { + SelectionContainer { + ClickableTextWithLinks( + text = part.text, + onClick = { url -> + try { + uriHandler.openUri(url) + } catch (e: Exception) { + // Fallback to intent + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + } + } + ) + } + } + is ContentPart.Image -> { + Spacer(modifier = Modifier.height(8.dp)) + ImageContent( + url = part.url, + onClick = { url -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + context.startActivity(intent) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + is ContentPart.YouTube -> { + Spacer(modifier = Modifier.height(8.dp)) + YouTubeEmbed( + videoId = part.videoId, + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://youtube.com/watch?v=${part.videoId}")) + context.startActivity(intent) + } + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + } + } +} + +/** + * Text component with clickable links + */ +@Composable +private fun ClickableTextWithLinks( + text: String, + onClick: (String) -> Unit +) { + val linkColor = MaterialTheme.colorScheme.primary + + // Find all URLs in text + val urlPattern = Regex("""https?://[^\s]+""") + val matches = urlPattern.findAll(text).toList() + + if (matches.isEmpty()) { + // No links, just show plain text + Text( + text = text, + style = MaterialTheme.typography.bodyMedium + ) + return + } + + // Build annotated string with clickable links + val annotatedString = buildAnnotatedString { + var lastIndex = 0 + + matches.forEach { match -> + // Add text before link + if (match.range.first > lastIndex) { + append(text.substring(lastIndex, match.range.first)) + } + + // Add clickable link + pushStringAnnotation(tag = "URL", annotation = match.value) + pushStyle( + SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline + ) + ) + append(match.value) + pop() + pop() + + lastIndex = match.range.last + 1 + } + + // Add remaining text + if (lastIndex < text.length) { + append(text.substring(lastIndex)) + } + } + + androidx.compose.foundation.text.ClickableText( + text = annotatedString, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ), + onClick = { offset -> + annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset) + .firstOrNull()?.let { annotation -> + onClick(annotation.item) + } + } + ) +} + +/** + * Image content (including GIFs) + */ +@Composable +private fun ImageContent( + url: String, + onClick: (String) -> Unit +) { + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(url) + .crossfade(true) + .build() + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick(url) } + ) { + Image( + painter = painter, + contentDescription = "Image", + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp), + contentScale = ContentScale.Fit + ) + } +} + +/** + * YouTube embed (thumbnail with play button) + */ +@Composable +private fun YouTubeEmbed( + videoId: String, + onClick: () -> Unit +) { + val thumbnailUrl = "https://img.youtube.com/vi/$videoId/hqdefault.jpg" + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + ) { + Box { + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(thumbnailUrl) + .crossfade(true) + .build() + ) + + Image( + painter = painter, + contentDescription = "YouTube video", + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp), + contentScale = ContentScale.Crop + ) + + // Play button overlay + Surface( + modifier = Modifier.align(Alignment.Center), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f) + ) { + Text( + text = "▶ Play on YouTube", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge + ) + } + } + } +} + +/** + * Parse message content into different parts (text, images, YouTube, etc.) + */ +private fun parseMessageContent(text: String): List { + val parts = mutableListOf() + + // Patterns + val imagePattern = Regex("""(https?://[^\s]+\.(jpg|jpeg|png|gif|webp))""", RegexOption.IGNORE_CASE) + val youtubePattern = Regex("""https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})""") + + // Find all media URLs + val imageMatches = imagePattern.findAll(text).toList() + val youtubeMatches = youtubePattern.findAll(text).toList() + + // Combine and sort by position + val allMatches = (imageMatches.map { it to "image" } + youtubeMatches.map { it to "youtube" }) + .sortedBy { it.first.range.first } + + if (allMatches.isEmpty()) { + // No media, just text + parts.add(ContentPart.Text(text)) + return parts + } + + var lastIndex = 0 + + allMatches.forEach { (match, type) -> + // Add text before media + if (match.range.first > lastIndex) { + val textBefore = text.substring(lastIndex, match.range.first).trim() + if (textBefore.isNotEmpty()) { + parts.add(ContentPart.Text(textBefore)) + } + } + + // Add media + when (type) { + "image" -> parts.add(ContentPart.Image(match.value)) + "youtube" -> { + val videoId = match.groupValues[1] + parts.add(ContentPart.YouTube(videoId)) + } + } + + lastIndex = match.range.last + 1 + } + + // Add remaining text + if (lastIndex < text.length) { + val textAfter = text.substring(lastIndex).trim() + if (textAfter.isNotEmpty()) { + parts.add(ContentPart.Text(textAfter)) + } + } + + return parts +} + +/** + * Content parts within a message + */ +private sealed class ContentPart { + data class Text(val text: String) : ContentPart() + data class Image(val url: String) : ContentPart() + data class YouTube(val videoId: String) : ContentPart() +} diff --git a/app/src/main/java/com/openclaw/alfred/ui/screens/MainScreen.kt b/app/src/main/java/com/openclaw/alfred/ui/screens/MainScreen.kt index cc1e71b..18174c3 100644 --- a/app/src/main/java/com/openclaw/alfred/ui/screens/MainScreen.kt +++ b/app/src/main/java/com/openclaw/alfred/ui/screens/MainScreen.kt @@ -1184,7 +1184,7 @@ fun MainScreen( state = listState ) { items(messages) { message -> - ChatMessageBubble(message) + com.openclaw.alfred.ui.components.RichMessageBubble(message) Spacer(modifier = Modifier.height(8.dp)) } }