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 92 additions and 61 deletions
Showing only changes of commit a32d53918e - Show all commits

View File

@@ -59,6 +59,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -74,6 +75,7 @@ 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
@@ -104,18 +106,77 @@ 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.URL_REGEX
import su.reya.coop.formatAsGroupHeader import su.reya.coop.formatAsGroupHeader
import su.reya.coop.formatAsTime import su.reya.coop.formatAsTime
import su.reya.coop.humanReadable import su.reya.coop.humanReadable
import su.reya.coop.isImageUrl 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
import su.reya.coop.short import su.reya.coop.short
@Immutable
data class MessageModel(
val id: String,
val annotatedContent: AnnotatedString,
val images: List<String>,
val timestamp: String,
val isMine: Boolean
)
@Composable
fun rememberMessageModel(
event: UnsignedEvent,
currentUserPublicKey: PublicKey?,
contentColor: Color
): MessageModel {
return remember(event, currentUserPublicKey, contentColor) {
val content = event.content()
val images = URL_REGEX.findAll(content)
.map { it.value }
.filter { it.isImageUrl() }
.toList()
val cleanedContent = URL_REGEX.replace(content) { result ->
if (result.value.isImageUrl()) "" else result.value
}.replace(Regex("\\s+"), " ").trim()
val annotatedString = buildAnnotatedString {
var lastIndex = 0
URL_REGEX.findAll(cleanedContent).forEach { matchResult ->
append(cleanedContent.substring(lastIndex, matchResult.range.first))
val url = matchResult.value
pushLink(
LinkAnnotation.Url(
url = url,
styles = TextLinkStyles(
style = SpanStyle(
color = contentColor,
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Medium
)
)
)
)
append(url)
pop()
lastIndex = matchResult.range.last + 1
}
append(cleanedContent.substring(lastIndex))
}
MessageModel(
id = event.id()?.toHex() ?: event.hashCode().toString(),
annotatedContent = annotatedString,
images = images,
timestamp = event.createdAt().formatAsTime(),
isMine = event.author() == currentUserPublicKey
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ChatScreen(id: Long, screening: Boolean = false) { fun ChatScreen(id: Long, screening: Boolean = false) {
@@ -156,9 +217,8 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
var requireScreening by remember { mutableStateOf(screening) } var requireScreening by remember { mutableStateOf(screening) }
val messages = remember { mutableStateListOf<UnsignedEvent>() } val messages = remember { mutableStateListOf<UnsignedEvent>() }
val groupedMessages = remember(messages.toList()) { val groupedMessages =
messages.groupBy { it.createdAt().formatAsGroupHeader() } remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } }
}
val sendFile = { uri: Uri -> val sendFile = { uri: Uri ->
scope.launch { scope.launch {
@@ -295,6 +355,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
room?.let { ScreenerCard(it) } room?.let { ScreenerCard(it) }
} }
val mineColor = MaterialTheme.colorScheme.onPrimaryContainer
val otherColor = MaterialTheme.colorScheme.onSurface
when (messages.isNotEmpty()) { when (messages.isNotEmpty()) {
true -> { true -> {
LazyColumn( LazyColumn(
@@ -305,15 +368,18 @@ fun ChatScreen(id: Long, screening: Boolean = false) {
reverseLayout = true, reverseLayout = true,
state = listState, state = listState,
) { ) {
groupedMessages.forEach { (dateHeader, messagesInGroup) -> groupedMessages.value.forEach { (dateHeader, messagesInGroup) ->
items( items(
items = messagesInGroup, items = messagesInGroup,
key = { it.id()?.toBech32() ?: it.hashCode() } key = { it.id()?.toHex() ?: it.hashCode().toString() }
) { ) { event ->
ChatMessage( val isMine = currentUser?.publicKey == event.author()
rumor = it, val uiModel = rememberMessageModel(
isMine = currentUser?.publicKey == it.author() event = event,
currentUserPublicKey = currentUser?.publicKey,
contentColor = if (isMine) mineColor else otherColor
) )
ChatMessage(model = uiModel)
} }
item { item {
DateSeparator(dateHeader) DateSeparator(dateHeader)
@@ -557,61 +623,26 @@ fun DateSeparator(date: String) {
} }
@Composable @Composable
fun ChatMessage( fun ChatMessage(model: MessageModel) {
rumor: UnsignedEvent, var isMessageClicked by remember { mutableStateOf(false) }
isMine: Boolean = false,
) {
val content = rumor.content()
val images = remember(content) { content.extractUrls().filter { it.isImageUrl() } }
val timestamp = remember(rumor) { rumor.createdAt().formatAsTime() }
val bubbleShape = if (isMine) { val bubbleShape = if (model.isMine) {
RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp) RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp)
} else { } else {
RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.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.surfaceContainer else MaterialTheme.colorScheme.primaryContainer if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer
val contentColor = val contentColor =
if (!isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer
val annotatedContent = remember(content, contentColor) {
buildAnnotatedString {
val cleanedContent = content.removeImageUrls()
val urlRegex = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
var lastIndex = 0
urlRegex.findAll(cleanedContent).forEach { matchResult ->
append(cleanedContent.substring(lastIndex, matchResult.range.first))
val url = matchResult.value
pushLink(
LinkAnnotation.Url(
url = url,
styles = TextLinkStyles(
style = SpanStyle(
color = contentColor,
textDecoration = TextDecoration.Underline,
fontWeight = FontWeight.Medium
)
)
)
)
append(url)
pop()
lastIndex = matchResult.range.last + 1
}
append(cleanedContent.substring(lastIndex))
}
}
var isMessageClicked by remember { mutableStateOf(false) }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart
) { ) {
Column( Column(
modifier = Modifier.clickable( modifier = Modifier.clickable(
@@ -620,10 +651,10 @@ fun ChatMessage(
) { ) {
isMessageClicked = !isMessageClicked isMessageClicked = !isMessageClicked
}, },
horizontalAlignment = if (isMine) Alignment.End else Alignment.Start, horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
if (annotatedContent.isNotBlank()) { if (model.annotatedContent.isNotBlank()) {
Surface( Surface(
color = containerColor, color = containerColor,
contentColor = contentColor, contentColor = contentColor,
@@ -631,13 +662,13 @@ fun ChatMessage(
modifier = Modifier.widthIn(max = 280.dp) modifier = Modifier.widthIn(max = 280.dp)
) { ) {
Text( Text(
text = annotatedContent, text = model.annotatedContent,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
} }
} }
images.forEach { imageUrl -> model.images.forEach { imageUrl ->
Surface( Surface(
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
@@ -659,11 +690,11 @@ fun ChatMessage(
exit = fadeOut() + shrinkVertically() exit = fadeOut() + shrinkVertically()
) { ) {
Text( Text(
text = timestamp, text = model.timestamp,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
modifier = Modifier.align( modifier = Modifier.align(
if (isMine) Alignment.End else Alignment.Start if (model.isMine) Alignment.End else Alignment.Start
) )
) )
} }

View File

@@ -7,15 +7,15 @@ fun PublicKey.short(): String {
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) val URL_REGEX = Regex("(https?://\\S+)", RegexOption.IGNORE_CASE)
private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp") private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "bmp")
fun String.extractUrls(): List<String> { fun String.extractUrls(): List<String> {
return urlRegex.findAll(this).map { it.value }.toList() return URL_REGEX.findAll(this).map { it.value }.toList()
} }
fun String.removeImageUrls(): String { fun String.removeImageUrls(): String {
return urlRegex.replace(this) { result -> return URL_REGEX.replace(this) { result ->
if (result.value.isImageUrl()) "" else result.value if (result.value.isImageUrl()) "" else result.value
}.replace(Regex("\\s+"), " ").trim() }.replace(Regex("\\s+"), " ").trim()
} }