From 32cfa4042c2a94cb3ab5e250fc376ef7f17120e4 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 14 Jul 2026 16:29:29 +0700 Subject: [PATCH] add reply --- .../su/reya/coop/screens/chat/ChatMessage.kt | 32 +++--- .../su/reya/coop/screens/chat/ChatScreen.kt | 108 ++++++++++++------ .../commonMain/kotlin/su/reya/coop/Room.kt | 2 +- .../coop/viewmodel/ChatScreenViewModel.kt | 6 +- 4 files changed, 90 insertions(+), 58 deletions(-) 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 index 6b67afd..8c3000f 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatMessage.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInWindow @@ -41,6 +40,7 @@ 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.EventId import rust.nostr.sdk.PublicKey import rust.nostr.sdk.UnsignedEvent import su.reya.coop.URL_REGEX @@ -49,8 +49,9 @@ import su.reya.coop.isImageUrl import su.reya.coop.removeImageUrls @Immutable -data class MessageUiModel( - val id: String, +data class MessageModel( + val id: EventId, + val author: PublicKey, val annotatedContent: AnnotatedString, val images: List, val timestamp: String, @@ -58,18 +59,13 @@ data class MessageUiModel( ) @Composable -fun rememberMessageUiModel( - event: UnsignedEvent, - currentUserPublicKey: PublicKey?, - contentColor: Color -): MessageUiModel { - return remember(event, currentUserPublicKey, contentColor) { +fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel { + return remember(event, currentUser) { + val id = event.ensureId().id()!! + val isMine = currentUser == event.author() val content = event.content() - val images = URL_REGEX.findAll(content) - .map { it.value } - .filter { it.isImageUrl() } - .toList() + val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList() val cleanedContent = content.removeImageUrls() val annotatedString = buildAnnotatedString { @@ -82,7 +78,6 @@ fun rememberMessageUiModel( url = url, styles = TextLinkStyles( style = SpanStyle( - color = contentColor, textDecoration = TextDecoration.Underline, fontWeight = FontWeight.Medium ) @@ -96,19 +91,20 @@ fun rememberMessageUiModel( append(cleanedContent.substring(lastIndex)) } - MessageUiModel( - id = event.id()?.toHex() ?: event.hashCode().toString(), + MessageModel( + id = id, + author = event.author(), annotatedContent = annotatedString, images = images, timestamp = event.createdAt().formatAsTime(), - isMine = event.author() == currentUserPublicKey + isMine = isMine, ) } } @Composable fun ChatMessage( - model: MessageUiModel, + model: MessageModel, modifier: Modifier = Modifier, onLongClick: (Rect) -> Unit = {} ) { diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt index 64e8f0c..985f645 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/chat/ChatScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material3.DropdownMenuGroup import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LoadingIndicator @@ -84,7 +83,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_copy -import coop.composeapp.generated.resources.ic_info import coop.composeapp.generated.resources.ic_reply import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -97,7 +95,7 @@ import su.reya.coop.LocalSnackbarHostState import su.reya.coop.Room import su.reya.coop.RoomUiState import su.reya.coop.Screen -import su.reya.coop.formatAsGroupHeader +import su.reya.coop.formatAsGroup import su.reya.coop.shared.Avatar import su.reya.coop.uiStateFlow import su.reya.coop.viewmodel.AccountViewModel @@ -138,19 +136,20 @@ fun ChatScreen( return } + val loading = viewModel.loading + val newOtherMessages = viewModel.newOtherMessages + val requireScreening = viewModel.requireScreening + val messages = viewModel.messages + + val groupedMessages = + remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } } + val roomState by (room as Room).uiStateFlow(profileCache, currentUser?.publicKey) .collectAsStateWithLifecycle(RoomUiState()) var text by remember { mutableStateOf("") } - var selectedMessage by remember { mutableStateOf?>(null) } - - val loading = viewModel.loading - val newOtherMessages = viewModel.newOtherMessages - val requireScreening = viewModel.requireScreening - - val messages = viewModel.messages - val groupedMessages = - remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroupHeader() } } } + var selectedMessage by remember { mutableStateOf?>(null) } + var replyingTo by remember { mutableStateOf(null) } val blurAmount by animateDpAsState( targetValue = if (selectedMessage != null) 8.dp else 0.dp, @@ -267,9 +266,6 @@ fun ChatScreen( room?.let { ScreenerCard(accountViewModel, it) } } - val mineColor = MaterialTheme.colorScheme.onPrimaryContainer - val otherColor = MaterialTheme.colorScheme.onSurface - when (messages.isNotEmpty()) { true -> { LazyColumn( @@ -283,24 +279,19 @@ fun ChatScreen( groupedMessages.value.forEach { (dateHeader, messagesInGroup) -> items( items = messagesInGroup, - key = { it.id()?.toHex() ?: it.hashCode().toString() } - ) { event -> - val isMine = currentUser?.publicKey == event.author() - val uiModel = rememberMessageUiModel( - event = event, - currentUserPublicKey = currentUser?.publicKey, - contentColor = if (isMine) mineColor else otherColor - ) - val isHighlighted = - selectedMessage?.first?.id == uiModel.id + key = { it.ensureId().id()?.toHex()!! } + ) { + val model = + rememberMessageModel(it, currentUser?.publicKey) ChatMessage( - model = uiModel, + model = model, modifier = Modifier.graphicsLayer { - alpha = if (isHighlighted) 0f else 1f + alpha = + if (selectedMessage?.first?.id == model.id) 0f else 1f }, onLongClick = { rect -> - selectedMessage = uiModel to rect + selectedMessage = model to rect } ) } @@ -373,6 +364,9 @@ fun ChatScreen( } else -> { + replyingTo?.let { + ReplyBox(it) { replyingTo = null } + } ChatInput( value = text, onValueChange = { text = it }, @@ -471,6 +465,10 @@ fun ChatScreen( } } + "Reply" -> { + replyingTo = model + } + else -> {} } selectedMessage = null @@ -481,13 +479,54 @@ fun ChatScreen( } } +@Composable +private fun ReplyBox(model: MessageModel, onDismiss: () -> Unit) { + val profileCache = LocalProfileCache.current + val profileFlow = remember(model) { profileCache.getMetadata(model.author) } + val profile by profileFlow.collectAsStateWithLifecycle() + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onDismiss() }, + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = RoundedCornerShape(16.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = "Replying to ${profile?.name}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy( + alpha = 0.6f + ), + ) + Text( + text = model.annotatedContent.toString().ifBlank { + if (model.images.isNotEmpty()) "[Image]" else "" + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } + } +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun ContextMenu(onAction: (String) -> Unit) { val menuItems = listOf( - Triple("Copy", Res.drawable.ic_copy, false), - Triple("Reply", Res.drawable.ic_reply, false), - Triple("Info", Res.drawable.ic_info, false), + "Copy" to Res.drawable.ic_copy, + "Reply" to Res.drawable.ic_reply ) DropdownMenuGroup( @@ -497,7 +536,7 @@ private fun ContextMenu(onAction: (String) -> Unit) { ) { val itemCount = menuItems.size - menuItems.forEachIndexed { index, (label, icon, hasDivider) -> + menuItems.forEachIndexed { index, (label, icon) -> DropdownMenuItem( shapes = MenuDefaults.itemShape(index, itemCount), colors = MenuDefaults.selectableItemVibrantColors(), @@ -511,11 +550,6 @@ private fun ContextMenu(onAction: (String) -> Unit) { checked = false, onCheckedChange = { _ -> onAction(label) }, ) - if (hasDivider) { - HorizontalDivider( - modifier = Modifier.padding(vertical = 4.dp), - ) - } } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index 65d1a98..2a01f29 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -150,7 +150,7 @@ fun Timestamp.ago(): String { } } -fun Timestamp.formatAsGroupHeader(): String { +fun Timestamp.formatAsGroup(): String { val timeZone = TimeZone.currentSystemDefault() val inputInstant = Instant.fromEpochSeconds(this.asSecs().toLong()) val inputDate = inputInstant.toLocalDateTime(timeZone).date diff --git a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt index 6852380..bec9f34 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import rust.nostr.sdk.EventId import rust.nostr.sdk.UnsignedEvent import su.reya.coop.Profile import su.reya.coop.Room @@ -62,8 +63,9 @@ class ChatScreenViewModel( } } - fun sendMessage(text: String) { - chatRepository.sendMessage(id, text) + fun sendMessage(text: String, replyTo: EventId? = null) { + val replyToList = if (replyTo != null) listOf(replyTo) else emptyList() + chatRepository.sendMessage(id, text, replyToList) } fun sendFileMessage(file: ByteArray?, type: String?) {