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..50e7c99 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,27 +49,25 @@ 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, - val isMine: Boolean + val isMine: Boolean, + val replyEventIds: List ) @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 replyEventIds = event.tags().eventIds() + val images = URL_REGEX.findAll(content).map { it.value }.filter { it.isImageUrl() }.toList() val cleanedContent = content.removeImageUrls() val annotatedString = buildAnnotatedString { @@ -82,7 +80,6 @@ fun rememberMessageUiModel( url = url, styles = TextLinkStyles( style = SpanStyle( - color = contentColor, textDecoration = TextDecoration.Underline, fontWeight = FontWeight.Medium ) @@ -96,19 +93,21 @@ 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, + replyEventIds = replyEventIds ) } } @Composable fun ChatMessage( - model: MessageUiModel, + model: MessageModel, modifier: Modifier = Modifier, onLongClick: (Rect) -> Unit = {} ) { @@ -146,14 +145,14 @@ fun ChatMessage( } ), horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(4.dp) ) { if (model.annotatedContent.isNotBlank()) { Surface( + modifier = Modifier.widthIn(max = 280.dp), color = containerColor, contentColor = contentColor, shape = bubbleShape, - modifier = Modifier.widthIn(max = 280.dp) ) { Text( text = model.annotatedContent, 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..1e7fc78 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 @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -43,7 +44,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,20 +84,20 @@ 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 import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.painterResource +import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalNavigator import su.reya.coop.LocalProfileCache 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 +138,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,42 +268,48 @@ fun ChatScreen( room?.let { ScreenerCard(accountViewModel, it) } } - val mineColor = MaterialTheme.colorScheme.onPrimaryContainer - val otherColor = MaterialTheme.colorScheme.onSurface - when (messages.isNotEmpty()) { true -> { LazyColumn( modifier = Modifier .weight(1f) .fillMaxWidth(), - contentPadding = PaddingValues(16.dp), - reverseLayout = true, state = listState, + reverseLayout = true, + contentPadding = PaddingValues(16.dp), ) { groupedMessages.value.forEach { (dateHeader, messagesInGroup) -> items( items = messagesInGroup, - key = { it.id()?.toHex() ?: it.hashCode().toString() } + key = { it.ensureId().id()?.toHex()!! } ) { 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 + val model = + rememberMessageModel(event, currentUser?.publicKey) - ChatMessage( - model = uiModel, - modifier = Modifier.graphicsLayer { - alpha = if (isHighlighted) 0f else 1f - }, - onLongClick = { rect -> - selectedMessage = uiModel to rect + val replyPreview = + remember(model.replyEventIds, messages.size) { + model.replyEventIds.firstOrNull() + ?.let { replyId -> + messages.find { it.id() == replyId } + } } - ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + replyPreview?.let { ReplyPreview(it, model.isMine) } + ChatMessage( + model = model, + modifier = Modifier.graphicsLayer { + alpha = + if (selectedMessage?.first?.id == model.id) 0f else 1f + }, + onLongClick = { rect -> + selectedMessage = model to rect + } + ) + } } item { DateSeparator(dateHeader) @@ -373,6 +380,9 @@ fun ChatScreen( } else -> { + replyingTo?.let { + ReplyBox(it) { replyingTo = null } + } ChatInput( value = text, onValueChange = { text = it }, @@ -471,6 +481,10 @@ fun ChatScreen( } } + "Reply" -> { + replyingTo = model + } + else -> {} } selectedMessage = null @@ -481,13 +495,98 @@ 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, + ) + } + } + } +} + +@Composable +private fun ReplyPreview(event: UnsignedEvent, isMine: Boolean = false) { + val profileCache = LocalProfileCache.current + val profileFlow = remember(event) { profileCache.getMetadata(event.author()) } + val profile by profileFlow.collectAsStateWithLifecycle() + + val bubbleShape = if (isMine) { + RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 20.dp, bottomEnd = 4.dp) + } else { + RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp, bottomStart = 4.dp, bottomEnd = 20.dp) + } + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = if (isMine) Alignment.CenterEnd else Alignment.CenterStart + ) { + Surface( + modifier = Modifier.widthIn(max = 280.dp), + color = MaterialTheme.colorScheme.tertiaryContainer, + shape = bubbleShape, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Text( + text = profile?.name ?: "Unknown", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer.copy( + alpha = 0.6f + ), + ) + Text( + text = event.content(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onTertiaryContainer, + maxLines = 1, + ) + } + } + } +} + @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 +596,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 +610,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/repository/ChatRepository.kt b/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt index 5647b76..6dbe60a 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt @@ -1,7 +1,9 @@ package su.reya.coop.repository import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -33,6 +35,7 @@ class ChatRepository( private val nostr: Nostr, private val mediaRepository: MediaRepository, private val scope: CoroutineScope, + private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default, ) : ErrorHost by createErrorHost() { private val _state = MutableStateFlow(ChatState()) val state = _state.stateIn( @@ -58,7 +61,7 @@ class ChatRepository( .stateIn(scope, SharingStarted.WhileSubscribed(5000), false) init { - scope.launch { + scope.launch(defaultDispatcher) { nostr.waitUntilInitialized() // Observe message sync progress @@ -137,7 +140,7 @@ class ChatRepository( } fun refreshChatRooms() { - scope.launch { + scope.launch(defaultDispatcher) { try { val rooms = nostr.messages.getChatRooms() ?: emptySet() _state.update { currentState -> @@ -166,7 +169,7 @@ class ChatRepository( } fun chatRoomConnect(roomId: Long) { - scope.launch { + scope.launch(defaultDispatcher) { try { val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") val members = room.members @@ -183,7 +186,7 @@ class ChatRepository( showError("Message cannot be empty") return } - scope.launch { + scope.launch(defaultDispatcher) { try { val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") nostr.messages.sendMessage( @@ -193,7 +196,7 @@ class ChatRepository( replies = replies, onRumorCreated = { event -> updateRoomList(roomId, event) - scope.launch { _newEvents.tryEmit(event) } + scope.launch(defaultDispatcher) { _newEvents.tryEmit(event) } }, ) } catch (e: Exception) { 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 44b300f..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 @@ -39,7 +40,7 @@ class ChatScreenViewModel( private fun loadMessages() { chatRepository.loadChatRoomMessages(id) { initialMessages -> messages.clear() - messages.addAll(initialMessages) + messages.addAll(initialMessages.distinctBy { it.id() }) loading = false } } @@ -52,7 +53,7 @@ class ChatScreenViewModel( viewModelScope.launch { chatRepository.newEvents.collect { event -> if (event.roomId() == id) { - if (event.id() !in messages.map { it.id() }) { + if (messages.none { it.id() == event.id() }) { messages.add(0, event) } } else { @@ -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?) {