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)
This commit is contained in:
2026-02-11 08:00:43 -08:00
parent 6d4ae2e5c3
commit eb15e29c8b
4 changed files with 333 additions and 4 deletions

View File

@@ -17,8 +17,8 @@ android {
applicationId = "com.openclaw.alfred" applicationId = "com.openclaw.alfred"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 35 versionCode = 41
versionName = "1.4.1" versionName = "1.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -132,6 +132,10 @@ dependencies {
// Vosk speech recognition for wake word // Vosk speech recognition for wake word
implementation("com.alphacephei:vosk-android:0.3.47") 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 // Testing
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.ext:junit:1.1.5")

View File

@@ -512,7 +512,7 @@ class GatewayClient(
) )
val json = gson.toJson(connectMsg) 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) val sent = webSocket?.send(json)
Log.d(TAG, "Send result: $sent") Log.d(TAG, "Send result: $sent")
} }

View File

@@ -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<ContentPart> {
val parts = mutableListOf<ContentPart>()
// 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()
}

View File

@@ -1184,7 +1184,7 @@ fun MainScreen(
state = listState state = listState
) { ) {
items(messages) { message -> items(messages) { message ->
ChatMessageBubble(message) com.openclaw.alfred.ui.components.RichMessageBubble(message)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
} }