feat: render image in chat message #36
@@ -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
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user