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 a01ff76..9ae62f2 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 @@ -5,9 +5,14 @@ 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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -22,6 +27,7 @@ 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 @@ -34,6 +40,7 @@ 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 androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import rust.nostr.sdk.EventId import rust.nostr.sdk.PublicKey @@ -46,6 +53,13 @@ import su.reya.coop.formatAsTime import su.reya.coop.isImageUrl import su.reya.coop.removeImageUrls +@Immutable +data class ReactionGroup( + val emoji: String, + val authors: List, + val containsMe: Boolean +) + @Immutable data class MessageModel( val id: EventId, @@ -54,15 +68,20 @@ data class MessageModel( val images: List, val timestamp: String, val isMine: Boolean, - val replyEventIds: List + val replyEventIds: List, + val reactions: List = emptyList() ) @Composable -fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): MessageModel { +fun rememberMessageModel( + event: UnsignedEvent, + reactions: List = emptyList(), + currentUser: PublicKey? = null +): MessageModel { val settings = LocalSettings.current val isMobileData = LocalConnectivity.current - return remember(event, currentUser, settings, isMobileData) { + return remember(event, reactions, currentUser, settings, isMobileData) { val id = event.ensureId().id()!! val isMine = currentUser == event.author() val content = event.content() @@ -104,6 +123,16 @@ fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): append(cleanedContent.substring(lastIndex)) } + val groupedReactions = reactions.groupBy { it.content() } + .map { (emoji, events) -> + val authors = events.map { it.author() } + ReactionGroup( + emoji = emoji, + authors = authors, + containsMe = authors.any { it == currentUser } + ) + } + MessageModel( id = id, author = event.author(), @@ -111,7 +140,8 @@ fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): images = images, timestamp = event.createdAt().formatAsTime(), isMine = isMine, - replyEventIds = replyEventIds + replyEventIds = replyEventIds, + reactions = groupedReactions ) } } @@ -188,6 +218,14 @@ fun ChatMessage( ) } } + + if (model.reactions.isNotEmpty()) { + ReactionsRow( + reactions = model.reactions, + modifier = Modifier.padding(top = 2.dp) + ) + } + if (isMessageClicked) { Text( text = model.timestamp, @@ -201,3 +239,57 @@ fun ChatMessage( } } } + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ReactionsRow( + reactions: List, + modifier: Modifier = Modifier +) { + FlowRow( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + reactions.forEach { group -> + ReactionChip(group) + } + } +} + +@Composable +private fun ReactionChip(group: ReactionGroup) { + val backgroundColor = if (group.containsMe) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } + + val contentColor = if (group.containsMe) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + Surface( + color = backgroundColor, + contentColor = contentColor, + shape = CircleShape, + modifier = Modifier.padding(vertical = 2.dp) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text(text = group.emoji, fontSize = 14.sp) + if (group.authors.size > 1) { + Text( + text = group.authors.size.toString(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold + ) + } + } + } +} 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 0ef0464..b300591 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 @@ -80,6 +80,7 @@ import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.ic_arrow_back @@ -91,6 +92,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.compose.resources.painterResource import rust.nostr.sdk.EventId +import rust.nostr.sdk.KindStandard import rust.nostr.sdk.UnsignedEvent import su.reya.coop.LocalNavigator import su.reya.coop.LocalProfileCache @@ -144,10 +146,21 @@ fun ChatScreen( val loading = viewModel.loading val newOtherMessages = viewModel.newOtherMessages val requireScreening = viewModel.requireScreening - val messages = viewModel.messages + val allEvents = viewModel.messages + + val displayMessages by remember { + derivedStateOf { allEvents.filter { it.kind().asStd() != KindStandard.REACTION } } + } + + val reactionsByMessage by remember { + derivedStateOf { + allEvents.filter { it.kind().asStd() == KindStandard.REACTION } + .groupBy { it.tags().eventIds().firstOrNull() } + } + } val groupedMessages = - remember { derivedStateOf { messages.groupBy { it.createdAt().formatAsGroup() } } } + remember { derivedStateOf { displayMessages.groupBy { it.createdAt().formatAsGroup() } } } val roomState by remember(id, currentUser?.publicKey) { (room as Room).uiStateFlow(profileCache, currentUser?.publicKey) @@ -170,7 +183,7 @@ fun ChatScreen( for (group in groupedMessages.value) { val msgInGroup = group.value - val idx = msgInGroup.indexOfFirst { it.id() == eventId } + val idx = msgInGroup.indexOfFirst { it.ensureId().id() == eventId } if (idx != -1) { targetIndex = currentIndex + idx break @@ -214,8 +227,8 @@ fun ChatScreen( } } - LaunchedEffect(messages.size) { - if (messages.isNotEmpty()) { + LaunchedEffect(allEvents.size) { + if (displayMessages.isNotEmpty()) { listState.animateScrollToItem(0) } } @@ -293,7 +306,7 @@ fun ChatScreen( room?.let { ScreenerCard(accountViewModel, it) } } - when (messages.isNotEmpty()) { + when (displayMessages.isNotEmpty()) { true -> { LazyColumn( modifier = Modifier @@ -308,14 +321,15 @@ fun ChatScreen( items = messagesInGroup, key = { it.ensureId().id()?.toHex()!! } ) { event -> + val msgReactions = reactionsByMessage[event.id()] ?: emptyList() val model = - rememberMessageModel(event, currentUser?.publicKey) + rememberMessageModel(event, msgReactions, currentUser?.publicKey) val replyPreview = - remember(model.replyEventIds, messages.size) { + remember(model.replyEventIds, displayMessages.size) { model.replyEventIds.firstOrNull() ?.let { replyId -> - messages.find { it.id() == replyId } + displayMessages.find { it.ensureId().id() == replyId } } } @@ -506,24 +520,30 @@ fun ChatScreen( .padding(horizontal = 16.dp), contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart ) { - ContextMenu { action -> - when (action) { - "Copy" -> { - scope.launch { - val content = model.annotatedContent - val data = ClipData.newPlainText(content, content) - clipboardManager.setClipEntry(ClipEntry(data)) + ContextMenu( + onAction = { action -> + when (action) { + "Copy" -> { + scope.launch { + val content = model.annotatedContent + val data = ClipData.newPlainText(content, content) + clipboardManager.setClipEntry(ClipEntry(data)) + } } - } - "Reply" -> { - replyingTo = model - } + "Reply" -> { + replyingTo = model + } - else -> {} + else -> {} + } + selectedMessage = null + }, + onReaction = { reaction -> + viewModel.sendReaction(model.id, reaction) + selectedMessage = null } - selectedMessage = null - } + ) } } } @@ -624,17 +644,39 @@ private fun ReplyPreview( @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun ContextMenu(onAction: (String) -> Unit) { +private fun ContextMenu( + onAction: (String) -> Unit, + onReaction: (String) -> Unit +) { val menuItems = listOf( "Copy" to Res.drawable.ic_copy, "Reply" to Res.drawable.ic_reply ) + val reactionEmojis = listOf("👍", "❤️", "👀", "🔥", "🚀", "🎉") + DropdownMenuGroup( shapes = MenuDefaults.groupShape(1, 1), containerColor = MenuDefaults.groupVibrantContainerColor, - modifier = Modifier.width(220.dp) + modifier = Modifier.width(240.dp) ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + reactionEmojis.forEach { emoji -> + Text( + text = emoji, + modifier = Modifier + .clickable { onReaction(emoji) } + .padding(horizontal = 4.dp), + fontSize = 24.sp + ) + } + } + val itemCount = menuItems.size menuItems.forEachIndexed { index, (label, icon) -> diff --git a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt index 3e3a002..03d428f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/nostr/Messaging.kt @@ -144,12 +144,15 @@ class MessageManager(private val nostr: Nostr) { private suspend fun setCachedRumor(giftId: EventId, rumor: UnsignedEvent) { try { + val isReaction = rumor.kind().asStd() == KindStandard.REACTION + val kValue = if (isReaction) "reaction" else "dm" + // Construct reference tags val tags = listOf( Tag.identifier(giftId.toHex()), Tag.publicKey(rumor.author()), Tag.custom("r", listOf(rumor.roomId().toString())), - Tag.custom("k", listOf("14")) + Tag.custom("k", listOf("14", kValue)) ) // Set event kind @@ -348,4 +351,64 @@ class MessageManager(private val nostr: Nostr) { throw IllegalStateException("Failed to send message: ${e.message}", e) } } + + suspend fun sendReaction( + to: Set, + targetEventId: EventId, + reaction: String, + onRumorCreated: ((UnsignedEvent) -> Unit)? = null, + ) { + try { + val currentUser = + signer.getPublicKeyAsync() ?: throw IllegalStateException("User not signed in") + + val tags = mutableListOf() + tags.add(Tag.event(targetEventId)) + // Add public key tags for each recipient (including me) to ensure roomId consistency + to.forEach { pubkey -> + tags.add(Tag.publicKey(pubkey)) + } + + for (receiver in setOf(currentUser) + to) { + // Construct the rumor event + val rumor = EventBuilder(Kind.fromStd(KindStandard.REACTION), reaction) + .tags(tags) + .finalizeUnsigned(currentUser) + .ensureId() + + // Emit the rumor to the chat screen + if (receiver == currentUser) { + onRumorCreated?.invoke(rumor) + } + + // Construct the gift wrap event + val gift = nip59MakeGiftWrapAsync( + signer = signer, + receiverPubkey = receiver, + rumor = rumor, + extraTags = listOf( + Tag.custom("k", listOf("14")) + ) + ) + + // Send the event to receiver's NIP-17 relays + val output = client?.sendEvent( + event = gift, + target = SendEventTarget.toNip17(), + ackPolicy = AckPolicy.none(), + authenticationTimeout = Duration.parse("2s") + ) + + if (output != null) { + // Keep track of rumor IDs + val id = rumor.id() ?: throw IllegalStateException("Rumor ID is null") + rumorMap[id] = output.id + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + throw IllegalStateException("Failed to send reaction: ${e.message}", e) + } + } } 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 cb6ec28..71f9eed 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/repository/ChatRepository.kt @@ -241,8 +241,29 @@ class ChatRepository( } } + fun sendReaction(roomId: Long, targetEventId: EventId, reaction: String) { + scope.launch(defaultDispatcher) { + try { + val room = getChatRoom(roomId) ?: throw IllegalArgumentException("Room not found") + nostr.messages.sendReaction( + to = room.members, + targetEventId = targetEventId, + reaction = reaction, + onRumorCreated = { + scope.launch(defaultDispatcher) { + updateRoomState(it, roomId) + } + }, + ) + } catch (e: Exception) { + showError("Error: ${e.message}") + } + } + } + private suspend fun updateRoomState(event: UnsignedEvent, roomId: Long = event.roomId()) { val currentUser = nostr.signer.getPublicKeyAsync() ?: return + val isReaction = event.kind().asStd() == KindStandard.REACTION _state.update { currentState -> val rooms = currentState.rooms.toMutableMap() @@ -256,10 +277,11 @@ class ChatRepository( // New room discovery val newRoom = Room.new(event, currentUser, roomId).copy( kind = newKind, - unreadCount = if (isFromMe) 0 else 1 + unreadCount = if (isFromMe || isReaction) 0 else 1, + lastMessage = if (isReaction) null else event.content() ) rooms[newRoom.id] = newRoom - } else if (event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) { + } else if (!isReaction && event.createdAt().asSecs() >= existingRoom.createdAt.asSecs()) { // Only update preview if message is newer (handles sync/late arrivals) rooms[roomId] = existingRoom.copy( lastMessage = event.content(), @@ -268,10 +290,10 @@ class ChatRepository( unreadCount = if (isFromMe) existingRoom.unreadCount else existingRoom.unreadCount + 1 ) } else if (isFromMe && existingRoom.kind != RoomKind.Ongoing) { - // Even if it's an older message, if it's from me, the room is ongoing + // Even if it's an older message or reaction, if it's from me, the room is ongoing rooms[roomId] = existingRoom.copy(kind = RoomKind.Ongoing) } else { - // Don't update the room list state for older messages + // Don't update the room list state for older messages or reactions that don't change preview return@update currentState } currentState.copy(rooms = rooms) 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 60fd994..b2aad1b 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/viewmodel/ChatScreenViewModel.kt @@ -74,4 +74,8 @@ class ChatScreenViewModel( fun sendFileMessage(file: ByteArray?, type: String?) { chatRepository.sendFileMessage(id, file, type) } + + fun sendReaction(targetEventId: EventId, reaction: String) { + chatRepository.sendReaction(id, targetEventId, reaction) + } }