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:
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user