From a5f544d49ff7847468b88c853842f26063deae01 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 4 Jul 2026 08:35:16 +0700 Subject: [PATCH 1/5] add image render in chat message --- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 61 ++++++++++++++----- .../kotlin/su/reya/coop/Extensions.kt | 18 ++++++ 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 90736da..0d7afeb 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -66,12 +66,15 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_add_circle 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.Room import su.reya.coop.Screen +import su.reya.coop.extractUrls import su.reya.coop.formatAsGroupHeader import su.reya.coop.humanReadable +import su.reya.coop.isImageUrl import su.reya.coop.rememberUiState +import su.reya.coop.removeImageUrls import su.reya.coop.roomId import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily @@ -545,17 +551,21 @@ fun ChatMessage( rumor: UnsignedEvent, 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) { - 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 { - 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 = - if (isMine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.tertiaryContainer + if (!isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer val contentColor = - if (isMine) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onTertiaryContainer + if (!isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer Box( modifier = Modifier @@ -564,19 +574,38 @@ fun ChatMessage( contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart ) { Column( - horizontalAlignment = if (isMine) Alignment.End else Alignment.Start + horizontalAlignment = if (isMine) Alignment.End else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Surface( - color = containerColor, - contentColor = contentColor, - shape = bubbleShape, - modifier = Modifier.widthIn(max = 280.dp) - ) { - Text( - text = rumor.content(), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - style = MaterialTheme.typography.bodyMedium - ) + if (cleanedContent.isNotBlank()) { + Surface( + color = containerColor, + contentColor = contentColor, + shape = bubbleShape, + modifier = Modifier.widthIn(max = 280.dp) + ) { + Text( + text = rumor.content(), + 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 + ) + } } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt b/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt index 3c5d624..7952b10 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt @@ -6,3 +6,21 @@ fun PublicKey.short(): String { val bech32 = toBech32() 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 { + 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 +} -- 2.49.1 From 77d422f20e5dc1aa33fa9ef21bbee38bca0cea7d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 4 Jul 2026 08:49:36 +0700 Subject: [PATCH 2/5] show message timestamp --- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 28 +++++++++++++++++++ .../commonMain/kotlin/su/reya/coop/Room.kt | 22 +++++++++------ 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 0d7afeb..8d16d95 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -7,12 +7,16 @@ import android.speech.RecognizerIntent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -97,6 +101,7 @@ import su.reya.coop.Room import su.reya.coop.Screen import su.reya.coop.extractUrls import su.reya.coop.formatAsGroupHeader +import su.reya.coop.formatAsTime import su.reya.coop.humanReadable import su.reya.coop.isImageUrl import su.reya.coop.rememberUiState @@ -554,6 +559,9 @@ fun ChatMessage( val content = rumor.content() val images = remember(content) { content.extractUrls().filter { it.isImageUrl() } } val cleanedContent = remember(content) { content.removeImageUrls() } + val timestamp = remember(rumor) { rumor.createdAt().formatAsTime() } + + var isMessageClicked by remember { mutableStateOf(false) } val bubbleShape = if (isMine) { RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp) @@ -574,6 +582,12 @@ fun ChatMessage( contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart ) { Column( + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + isMessageClicked = !isMessageClicked + }, horizontalAlignment = if (isMine) Alignment.End else Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -607,6 +621,20 @@ fun ChatMessage( ) } } + AnimatedVisibility( + visible = isMessageClicked, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Text( + text = timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.align( + if (isMine) Alignment.End else Alignment.Start + ) + ) + } } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index b1c6c3e..5b7d8eb 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -153,21 +153,25 @@ fun UnsignedEvent.roomId(): Long { return sortedUniqueKeys.hashCode().toLong() } -fun Timestamp.ago(): String { - val SECONDS_IN_MINUTE = 60L - val MINUTES_IN_HOUR = 60L - val HOURS_IN_DAY = 24L - val DAYS_IN_MONTH = 30L +fun Timestamp.formatAsTime(): String { + val timeZone = TimeZone.currentSystemDefault() + val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong()) + val inputDateTime = inputInstant.toLocalDateTime(timeZone) + val hour = inputDateTime.hour.toString().padStart(2, '0') + val minute = inputDateTime.minute.toString().padStart(2, '0') + return "$hour:$minute" +} +fun Timestamp.ago(): String { val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong()) val now = Clock.System.now() val duration = now - inputInstant return when { - duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now" - duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m" - duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h" - duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d" + duration.inWholeSeconds < 60L -> "Now" + duration.inWholeMinutes < 60L -> "${duration.inWholeMinutes}m" + duration.inWholeHours < 24L -> "${duration.inWholeHours}h" + duration.inWholeDays < 30L -> "${duration.inWholeDays}d" else -> { val localDateTime = inputInstant.toLocalDateTime(TimeZone.currentSystemDefault()) val month = -- 2.49.1 From b29d0f522c210d74aa0a6d4854169b1dace95440 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 4 Jul 2026 09:02:24 +0700 Subject: [PATCH 3/5] support url in message --- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 8d16d95..68f63ce 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -74,8 +74,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage @@ -558,11 +563,8 @@ fun ChatMessage( ) { val content = rumor.content() val images = remember(content) { content.extractUrls().filter { it.isImageUrl() } } - val cleanedContent = remember(content) { content.removeImageUrls() } val timestamp = remember(rumor) { rumor.createdAt().formatAsTime() } - var isMessageClicked by remember { mutableStateOf(false) } - val bubbleShape = if (isMine) { RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp) } else { @@ -575,6 +577,36 @@ fun ChatMessage( val contentColor = if (!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( modifier = Modifier .fillMaxWidth() @@ -591,7 +623,7 @@ fun ChatMessage( horizontalAlignment = if (isMine) Alignment.End else Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - if (cleanedContent.isNotBlank()) { + if (annotatedContent.isNotBlank()) { Surface( color = containerColor, contentColor = contentColor, @@ -599,7 +631,7 @@ fun ChatMessage( modifier = Modifier.widthIn(max = 280.dp) ) { Text( - text = rumor.content(), + text = annotatedContent, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), style = MaterialTheme.typography.bodyLarge ) -- 2.49.1 From a32d53918e59e220e57df7de49c6249c5f784cff Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 4 Jul 2026 09:11:25 +0700 Subject: [PATCH 4/5] optimize --- .../kotlin/su/reya/coop/screens/ChatScreen.kt | 147 +++++++++++------- .../kotlin/su/reya/coop/Extensions.kt | 6 +- 2 files changed, 92 insertions(+), 61 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 68f63ce..b0e7aa8 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -59,6 +59,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.toShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf 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.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles @@ -104,18 +106,77 @@ import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room 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.formatAsTime import su.reya.coop.humanReadable import su.reya.coop.isImageUrl import su.reya.coop.rememberUiState -import su.reya.coop.removeImageUrls import su.reya.coop.roomId import su.reya.coop.shared.Avatar import su.reya.coop.shared.getExpressiveFontFamily import su.reya.coop.short +@Immutable +data class MessageModel( + val id: String, + val annotatedContent: AnnotatedString, + val images: List, + 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) @Composable fun ChatScreen(id: Long, screening: Boolean = false) { @@ -156,9 +217,8 @@ fun ChatScreen(id: Long, screening: Boolean = false) { var requireScreening by remember { mutableStateOf(screening) } val messages = remember { mutableStateListOf() } - val groupedMessages = remember(messages.toList()) { - messages.groupBy { it.createdAt().formatAsGroupHeader() } - } + val groupedMessages = + remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } } val sendFile = { uri: Uri -> scope.launch { @@ -295,6 +355,9 @@ fun ChatScreen(id: Long, screening: Boolean = false) { room?.let { ScreenerCard(it) } } + val mineColor = MaterialTheme.colorScheme.onPrimaryContainer + val otherColor = MaterialTheme.colorScheme.onSurface + when (messages.isNotEmpty()) { true -> { LazyColumn( @@ -305,15 +368,18 @@ fun ChatScreen(id: Long, screening: Boolean = false) { reverseLayout = true, state = listState, ) { - groupedMessages.forEach { (dateHeader, messagesInGroup) -> + groupedMessages.value.forEach { (dateHeader, messagesInGroup) -> items( items = messagesInGroup, - key = { it.id()?.toBech32() ?: it.hashCode() } - ) { - ChatMessage( - rumor = it, - isMine = currentUser?.publicKey == it.author() + key = { it.id()?.toHex() ?: it.hashCode().toString() } + ) { event -> + val isMine = currentUser?.publicKey == event.author() + val uiModel = rememberMessageModel( + event = event, + currentUserPublicKey = currentUser?.publicKey, + contentColor = if (isMine) mineColor else otherColor ) + ChatMessage(model = uiModel) } item { DateSeparator(dateHeader) @@ -557,61 +623,26 @@ fun DateSeparator(date: String) { } @Composable -fun ChatMessage( - rumor: UnsignedEvent, - isMine: Boolean = false, -) { - val content = rumor.content() - val images = remember(content) { content.extractUrls().filter { it.isImageUrl() } } - val timestamp = remember(rumor) { rumor.createdAt().formatAsTime() } +fun ChatMessage(model: MessageModel) { + var isMessageClicked by remember { mutableStateOf(false) } - val bubbleShape = if (isMine) { + val bubbleShape = if (model.isMine) { RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp) } else { RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp) } val containerColor = - if (!isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer + if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer val contentColor = - if (!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) } + if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer Box( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), - contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart + contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart ) { Column( modifier = Modifier.clickable( @@ -620,10 +651,10 @@ fun ChatMessage( ) { isMessageClicked = !isMessageClicked }, - horizontalAlignment = if (isMine) Alignment.End else Alignment.Start, + horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, verticalArrangement = Arrangement.spacedBy(8.dp) ) { - if (annotatedContent.isNotBlank()) { + if (model.annotatedContent.isNotBlank()) { Surface( color = containerColor, contentColor = contentColor, @@ -631,13 +662,13 @@ fun ChatMessage( modifier = Modifier.widthIn(max = 280.dp) ) { Text( - text = annotatedContent, + text = model.annotatedContent, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), style = MaterialTheme.typography.bodyLarge ) } } - images.forEach { imageUrl -> + model.images.forEach { imageUrl -> Surface( shape = RoundedCornerShape(16.dp), color = MaterialTheme.colorScheme.surfaceVariant, @@ -659,11 +690,11 @@ fun ChatMessage( exit = fadeOut() + shrinkVertically() ) { Text( - text = timestamp, + text = model.timestamp, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, modifier = Modifier.align( - if (isMine) Alignment.End else Alignment.Start + if (model.isMine) Alignment.End else Alignment.Start ) ) } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt b/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt index 7952b10..7b78f79 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Extensions.kt @@ -7,15 +7,15 @@ fun PublicKey.short(): String { 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") fun String.extractUrls(): List { - return urlRegex.findAll(this).map { it.value }.toList() + return URL_REGEX.findAll(this).map { it.value }.toList() } fun String.removeImageUrls(): String { - return urlRegex.replace(this) { result -> + return URL_REGEX.replace(this) { result -> if (result.value.isImageUrl()) "" else result.value }.replace(Regex("\\s+"), " ").trim() } -- 2.49.1 From 5b93d453e4f75ece402ee8b83e01db2fb1f73260 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 4 Jul 2026 09:22:18 +0700 Subject: [PATCH 5/5] restructure --- .../androidMain/kotlin/su/reya/coop/App.kt | 2 +- .../reya/coop/screens/chat/ChatComponents.kt | 177 ++++++++ .../su/reya/coop/screens/chat/ChatInput.kt | 97 +++++ .../su/reya/coop/screens/chat/ChatMessage.kt | 183 +++++++++ .../coop/screens/{ => chat}/ChatScreen.kt | 388 +----------------- 5 files changed, 460 insertions(+), 387 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt rename composeApp/src/androidMain/kotlin/su/reya/coop/screens/{ => chat}/ChatScreen.kt (53%) diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 8d242ce..d2b4f17 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -35,7 +35,7 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import su.reya.coop.repository.ErrorRepository -import su.reya.coop.screens.ChatScreen +import su.reya.coop.screens.chat.ChatScreen import su.reya.coop.screens.ContactListScreen import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.ImportScreen diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt new file mode 100644 index 0000000..ede3bf9 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatComponents.kt @@ -0,0 +1,177 @@ +package su.reya.coop.screens.chat + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_cancel +import coop.composeapp.generated.resources.ic_check_circle +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.Timestamp +import su.reya.coop.LocalNostrViewModel +import su.reya.coop.Room +import su.reya.coop.humanReadable +import su.reya.coop.shared.Avatar +import su.reya.coop.shared.getExpressiveFontFamily +import su.reya.coop.short + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ScreenerCard(room: Room) { + val pubkey = room.members.firstOrNull() ?: return + + val nostrViewModel = LocalNostrViewModel.current + val scope = rememberCoroutineScope() + + var isContact by remember { mutableStateOf(false) } + var mutualContacts by remember { mutableStateOf>(emptySet()) } + var lastActivity by remember { mutableStateOf(null) } + + val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } + val profile by profileFlow.collectAsStateWithLifecycle() + + LaunchedEffect(pubkey) { + scope.launch { + // Check contact + nostrViewModel.verifyContact(pubkey).let { isContact = it } + // Get mutual contacts + nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it } + // Get the last activity + nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 48.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Avatar( + picture = profile?.picture, + description = "Profile picture", + modifier = Modifier.size(120.dp), + shape = MaterialShapes.Cookie12Sided.toShape(), + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = profile?.name ?: "No name", + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLargeEmphasized.copy( + fontFamily = getExpressiveFontFamily() + ), + ) + Text( + text = pubkey.short(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline + ) + } + } + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + painter = painterResource( + if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel + ), + contentDescription = "Warning", + tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + ) + Text( + text = if (isContact) "Contact" else "Not a contact", + style = MaterialTheme.typography.labelMediumEmphasized + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + painter = painterResource( + if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel + ), + contentDescription = "Warning", + tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error + ) + Text( + text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common", + style = MaterialTheme.typography.labelMediumEmphasized + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + painter = painterResource(Res.drawable.ic_check_circle), + contentDescription = "Warning", + tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + ) + Text( + text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}", + style = MaterialTheme.typography.labelMediumEmphasized + ) + } + } + } +} + +@Composable +fun DateSeparator(date: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = date, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt new file mode 100644 index 0000000..7f09365 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatInput.kt @@ -0,0 +1,97 @@ +package su.reya.coop.screens.chat + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_add_circle +import coop.composeapp.generated.resources.ic_audio +import coop.composeapp.generated.resources.ic_send +import org.jetbrains.compose.resources.painterResource + +@Composable +fun ChatInput( + value: String, + onValueChange: (String) -> Unit, + onSend: () -> Unit, + onUpload: () -> Unit, + onMicClick: () -> Unit +) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.Bottom + ) { + TextField( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(28.dp), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + value = value, + onValueChange = onValueChange, + placeholder = { Text("Message") }, + leadingIcon = { + IconButton(onClick = onUpload) { + Icon( + painter = painterResource(Res.drawable.ic_add_circle), + contentDescription = "Upload", + ) + } + }, + ) + Spacer(modifier = Modifier.size(8.dp)) + AnimatedContent( + targetState = value.isNotEmpty(), + transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) }, + label = "send_mic_transition" + ) { isNotEmpty -> + if (isNotEmpty) { + IconButton( + onClick = onSend, + modifier = Modifier.size(56.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) { + Icon( + painter = painterResource(Res.drawable.ic_send), + contentDescription = "Send" + ) + } + } else { + FilledTonalIconButton( + onClick = onMicClick, + modifier = Modifier.size(56.dp), + ) { + Icon( + painter = painterResource(Res.drawable.ic_audio), + contentDescription = "Speech to Text" + ) + } + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt new file mode 100644 index 0000000..348bdce --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt @@ -0,0 +1,183 @@ +package su.reya.coop.screens.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.UnsignedEvent +import su.reya.coop.URL_REGEX +import su.reya.coop.formatAsTime +import su.reya.coop.isImageUrl +import su.reya.coop.removeImageUrls + +@Immutable +data class MessageUiModel( + val id: String, + val annotatedContent: AnnotatedString, + val images: List, + val timestamp: String, + val isMine: Boolean +) + +@Composable +fun rememberMessageUiModel( + event: UnsignedEvent, + currentUserPublicKey: PublicKey?, + contentColor: Color +): MessageUiModel { + return remember(event, currentUserPublicKey, contentColor) { + val content = event.content() + val images = URL_REGEX.findAll(content) + .map { it.value } + .filter { it.isImageUrl() } + .toList() + + val cleanedContent = content.removeImageUrls() + + 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)) + } + + MessageUiModel( + id = event.id()?.toHex() ?: event.hashCode().toString(), + annotatedContent = annotatedString, + images = images, + timestamp = event.createdAt().formatAsTime(), + isMine = event.author() == currentUserPublicKey + ) + } +} + +@Composable +fun ChatMessage(model: MessageUiModel) { + var isMessageClicked by remember { mutableStateOf(false) } + + val bubbleShape = if (model.isMine) { + RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp) + } else { + RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp) + } + + val containerColor = + if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer + + val contentColor = + if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart + ) { + Column( + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + isMessageClicked = !isMessageClicked + }, + horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (model.annotatedContent.isNotBlank()) { + Surface( + color = containerColor, + contentColor = contentColor, + shape = bubbleShape, + modifier = Modifier.widthIn(max = 280.dp) + ) { + Text( + text = model.annotatedContent, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodyLarge + ) + } + } + model.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 + ) + } + } + AnimatedVisibility( + visible = isMessageClicked, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Text( + text = model.timestamp, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.align( + if (model.isMine) Alignment.End else Alignment.Start + ) + ) + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt similarity index 53% rename from composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt rename to composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index b0e7aa8..2468f46 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -1,4 +1,4 @@ -package su.reya.coop.screens +package su.reya.coop.screens.chat import android.app.Activity import android.content.Intent @@ -6,17 +6,7 @@ import android.net.Uri import android.speech.RecognizerIntent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.togetherWith import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -30,7 +20,6 @@ import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.union -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -41,25 +30,18 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LoadingIndicator -import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.SnackbarHost import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.toShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -71,34 +53,16 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage import coop.composeapp.generated.resources.Res -import coop.composeapp.generated.resources.ic_add_circle import coop.composeapp.generated.resources.ic_arrow_back -import coop.composeapp.generated.resources.ic_audio -import coop.composeapp.generated.resources.ic_cancel -import coop.composeapp.generated.resources.ic_check_circle -import coop.composeapp.generated.resources.ic_send import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.painterResource -import rust.nostr.sdk.PublicKey -import rust.nostr.sdk.Timestamp import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalChatViewModel import su.reya.coop.LocalNavigator @@ -106,76 +70,10 @@ import su.reya.coop.LocalNostrViewModel import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room import su.reya.coop.Screen -import su.reya.coop.URL_REGEX import su.reya.coop.formatAsGroupHeader -import su.reya.coop.formatAsTime -import su.reya.coop.humanReadable -import su.reya.coop.isImageUrl import su.reya.coop.rememberUiState import su.reya.coop.roomId import su.reya.coop.shared.Avatar -import su.reya.coop.shared.getExpressiveFontFamily -import su.reya.coop.short - -@Immutable -data class MessageModel( - val id: String, - val annotatedContent: AnnotatedString, - val images: List, - 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) @Composable @@ -374,7 +272,7 @@ fun ChatScreen(id: Long, screening: Boolean = false) { key = { it.id()?.toHex() ?: it.hashCode().toString() } ) { event -> val isMine = currentUser?.publicKey == event.author() - val uiModel = rememberMessageModel( + val uiModel = rememberMessageUiModel( event = event, currentUserPublicKey = currentUser?.publicKey, contentColor = if (isMine) mineColor else otherColor @@ -485,285 +383,3 @@ fun ChatScreen(id: Long, screening: Boolean = false) { } ) } - -@OptIn(ExperimentalMaterial3ExpressiveApi::class) -@Composable -fun ScreenerCard(room: Room) { - val pubkey = room.members.firstOrNull() ?: return - - val nostrViewModel = LocalNostrViewModel.current - val scope = rememberCoroutineScope() - - var isContact by remember { mutableStateOf(false) } - var mutualContacts by remember { mutableStateOf>(emptySet()) } - var lastActivity by remember { mutableStateOf(null) } - - val profileFlow = remember(pubkey) { nostrViewModel.getMetadata(pubkey) } - val profile by profileFlow.collectAsStateWithLifecycle() - - LaunchedEffect(pubkey) { - scope.launch { - // Check contact - nostrViewModel.verifyContact(pubkey).let { isContact = it } - // Get mutual contacts - nostrViewModel.mutualContacts(pubkey).let { mutualContacts = it } - // Get the last activity - nostrViewModel.verifyActivity(pubkey)?.let { lastActivity = it } - } - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(top = 48.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Avatar( - picture = profile?.picture, - description = "Profile picture", - modifier = Modifier.size(120.dp), - shape = MaterialShapes.Cookie12Sided.toShape(), - ) - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = profile?.name ?: "No name", - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleLargeEmphasized.copy( - fontFamily = getExpressiveFontFamily() - ), - ) - Text( - text = pubkey.short(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.outline - ) - } - } - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - painter = painterResource( - if (isContact) Res.drawable.ic_check_circle else Res.drawable.ic_cancel - ), - contentDescription = "Warning", - tint = if (isContact) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error - ) - Text( - text = if (isContact) "Contact" else "Not a contact", - style = MaterialTheme.typography.labelMediumEmphasized - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - painter = painterResource( - if (mutualContacts.isNotEmpty()) Res.drawable.ic_check_circle else Res.drawable.ic_cancel - ), - contentDescription = "Warning", - tint = if (mutualContacts.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error - ) - Text( - text = if (mutualContacts.isEmpty()) "No contacts in common" else "${mutualContacts.size} contacts in common", - style = MaterialTheme.typography.labelMediumEmphasized - ) - } - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - painter = painterResource(Res.drawable.ic_check_circle), - contentDescription = "Warning", - tint = if (lastActivity != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline - ) - Text( - text = if (lastActivity == null) "Don't have any public activities" else "Last activity at ${lastActivity?.humanReadable()}", - style = MaterialTheme.typography.labelMediumEmphasized - ) - } - } - } -} - -@Composable -fun DateSeparator(date: String) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = date, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.outline - ) - } -} - -@Composable -fun ChatMessage(model: MessageModel) { - var isMessageClicked by remember { mutableStateOf(false) } - - val bubbleShape = if (model.isMine) { - RoundedCornerShape(topStart = 20.dp, topEnd = 4.dp, bottomStart = 20.dp, bottomEnd = 20.dp) - } else { - RoundedCornerShape(topStart = 4.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 20.dp) - } - - val containerColor = - if (!model.isMine) MaterialTheme.colorScheme.surfaceContainer else MaterialTheme.colorScheme.primaryContainer - - val contentColor = - if (!model.isMine) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimaryContainer - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart - ) { - Column( - modifier = Modifier.clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - isMessageClicked = !isMessageClicked - }, - horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (model.annotatedContent.isNotBlank()) { - Surface( - color = containerColor, - contentColor = contentColor, - shape = bubbleShape, - modifier = Modifier.widthIn(max = 280.dp) - ) { - Text( - text = model.annotatedContent, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - style = MaterialTheme.typography.bodyLarge - ) - } - } - model.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 - ) - } - } - AnimatedVisibility( - visible = isMessageClicked, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically() - ) { - Text( - text = model.timestamp, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.outline, - modifier = Modifier.align( - if (model.isMine) Alignment.End else Alignment.Start - ) - ) - } - } - } -} - -@Composable -fun ChatInput( - value: String, - onValueChange: (String) -> Unit, - onSend: () -> Unit, - onUpload: () -> Unit, - onMicClick: () -> Unit -) { - Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.Bottom - ) { - TextField( - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(28.dp), - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ), - value = value, - onValueChange = onValueChange, - placeholder = { Text("Message") }, - leadingIcon = { - IconButton(onClick = onUpload) { - Icon( - painter = painterResource(Res.drawable.ic_add_circle), - contentDescription = "Upload", - ) - } - }, - ) - Spacer(modifier = Modifier.size(8.dp)) - AnimatedContent( - targetState = value.isNotEmpty(), - transitionSpec = { (scaleIn() + fadeIn()) togetherWith (scaleOut() + fadeOut()) }, - label = "send_mic_transition" - ) { isNotEmpty -> - if (isNotEmpty) { - IconButton( - onClick = onSend, - modifier = Modifier.size(56.dp), - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - ) { - Icon( - painter = painterResource(Res.drawable.ic_send), - contentDescription = "Send" - ) - } - } else { - FilledTonalIconButton( - onClick = onMicClick, - modifier = Modifier.size(56.dp), - ) { - Icon( - painter = painterResource(Res.drawable.ic_audio), - contentDescription = "Speech to Text" - ) - } - } - } - } -} -- 2.49.1