feat: render image in chat message #36

Merged
reya merged 5 commits from feat/render-image into master 2026-07-04 02:25:10 +00:00
2 changed files with 63 additions and 16 deletions
Showing only changes of commit a5f544d49f - Show all commits

View File

@@ -66,12 +66,15 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_add_circle import coop.composeapp.generated.resources.ic_add_circle
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
@@ -92,9 +95,12 @@ import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Room import su.reya.coop.Room
import su.reya.coop.Screen import su.reya.coop.Screen
import su.reya.coop.extractUrls
import su.reya.coop.formatAsGroupHeader import su.reya.coop.formatAsGroupHeader
import su.reya.coop.humanReadable import su.reya.coop.humanReadable
import su.reya.coop.isImageUrl
import su.reya.coop.rememberUiState import su.reya.coop.rememberUiState
import su.reya.coop.removeImageUrls
import su.reya.coop.roomId import su.reya.coop.roomId
import su.reya.coop.shared.Avatar import su.reya.coop.shared.Avatar
import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.shared.getExpressiveFontFamily
@@ -545,17 +551,21 @@ fun ChatMessage(
rumor: UnsignedEvent, rumor: UnsignedEvent,
isMine: Boolean = false, isMine: Boolean = false,
) { ) {
val content = rumor.content()
val images = remember(content) { content.extractUrls().filter { it.isImageUrl() } }
val cleanedContent = remember(content) { content.removeImageUrls() }
val bubbleShape = if (isMine) { val bubbleShape = if (isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp) RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
} else { } else {
RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp) RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
} }
val containerColor = val containerColor =
if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer if (!isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer
val contentColor = val contentColor =
if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer if (!isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
Box( Box(
modifier = Modifier modifier = Modifier
@@ -564,19 +574,38 @@ fun ChatMessage(
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart
) { ) {
Column( Column(
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start horizontalAlignment = if (isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Surface( if (cleanedContent.isNotBlank()) {
color = containerColor, Surface(
contentColor = contentColor, color = containerColor,
shape = bubbleShape, contentColor = contentColor,
modifier = Modifier.widthIn(max = 280.dp) shape = bubbleShape,
) { modifier = Modifier.widthIn(max = 280.dp)
Text( ) {
text = rumor.content(), Text(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), text = rumor.content(),
style = MaterialTheme.typography.bodyMedium modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) style = MaterialTheme.typography.bodyLarge
)
}
}
images.forEach { imageUrl ->
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.widthIn(max = 280.dp)
) {
AsyncImage(
model = imageUrl,
contentDescription = "Image from chat",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.FillWidth
)
}
} }
} }
} }

View File

@@ -6,3 +6,21 @@ fun PublicKey.short(): String {
val bech32 = toBech32() val bech32 = toBech32()
return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4) return bech32.substring(0, 6) + "..." + bech32.substring(bech32.length - 4)
} }
private val urlRegex = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
fun String.extractUrls(): List<String> {
return urlRegex.findAll(this).map { it.value }.toList()
}
fun String.removeImageUrls(): String {
return urlRegex.replace(this) { result ->
if (result.value.isImageUrl()) "" else result.value
}.replace(Regex("\\s+"), " ").trim()
}
fun String.isImageUrl(): Boolean {
val extension = this.substringAfterLast('.', "").lowercase()
return extension in imageExtensions
}