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..8c7f56a 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,13 @@ 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.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset 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 @@ -34,6 +38,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 +51,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 +66,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 +121,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 +138,8 @@ fun rememberMessageModel(event: UnsignedEvent, currentUser: PublicKey? = null): images = images, timestamp = event.createdAt().formatAsTime(), isMine = isMine, - replyEventIds = replyEventIds + replyEventIds = replyEventIds, + reactions = groupedReactions ) } } @@ -158,46 +186,108 @@ fun ChatMessage( horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, verticalArrangement = Arrangement.spacedBy(4.dp) ) { - if (model.annotatedContent.isNotBlank()) { - Surface( - modifier = Modifier.widthIn(max = 280.dp), - color = containerColor, - contentColor = contentColor, - shape = bubbleShape, + Box(contentAlignment = Alignment.BottomEnd) { + Column( + modifier = Modifier.padding( + bottom = if (model.reactions.isNotEmpty()) 4.dp else 0.dp, + end = if (model.reactions.isNotEmpty() && !model.isMine) 8.dp else 0.dp + ), + horizontalAlignment = if (model.isMine) Alignment.End else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(4.dp) ) { - Text( - text = model.annotatedContent, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - style = MaterialTheme.typography.bodyLarge - ) + if (model.annotatedContent.isNotBlank()) { + Surface( + modifier = Modifier.widthIn(max = 280.dp), + color = containerColor, + contentColor = contentColor, + shape = bubbleShape, + ) { + 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 + ) + } + } } - } - 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 + + if (model.reactions.isNotEmpty()) { + MessageReactions( + reactions = model.reactions, + modifier = Modifier.offset(y = 12.dp) ) } } + if (isMessageClicked) { Text( text = model.timestamp, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline, - modifier = Modifier.align( - if (model.isMine) Alignment.End else Alignment.Start - ) + modifier = Modifier.padding(top = if (model.reactions.isNotEmpty()) 8.dp else 0.dp) ) } } } } + +@Composable +private fun MessageReactions( + reactions: List, + modifier: Modifier = Modifier +) { + val totalCount = reactions.sumOf { it.authors.size } + val displayEmojis = reactions.take(3).map { it.emoji } + + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + displayEmojis.forEach { emoji -> + Surface( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.surface, + shape = CircleShape, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = emoji, + fontSize = 12.sp, + ) + } + } + } + if (totalCount > 2) { + Surface( + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.surface, + shape = CircleShape, + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = totalCount.toString(), + style = MaterialTheme.typography.labelSmall, + fontSize = 10.sp, + ) + } + } + } + } +} 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..2a84d40 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 } } } @@ -464,14 +478,11 @@ fun ChatScreen( val (model, bounds) = selectedMessage ?: return@AnimatedVisibility val density = LocalDensity.current - val windowInfo = LocalWindowInfo.current - val windowHeight = windowInfo.containerSize.height val scrollState = rememberScrollState() var menuHeight by remember { mutableFloatStateOf(0f) } - val spacing = with(density) { 12.dp.toPx() } - val showAbove = - (windowHeight - bounds.bottom) < (menuHeight + spacing) && bounds.top > (menuHeight + spacing) + var toolbarHeight by remember { mutableFloatStateOf(0f) } + val spacing = with(density) { 6.dp.toPx() } Box( modifier = Modifier @@ -480,11 +491,30 @@ fun ChatScreen( .clickable { selectedMessage = null } .verticalScroll(scrollState), ) { - val totalExtraHeight = if (menuHeight > 0) menuHeight + spacing else 300f + val totalExtraHeight = (if (menuHeight > 0) menuHeight + spacing else 300f) + + (if (toolbarHeight > 0) toolbarHeight + spacing else 100f) val contentBottom = with(density) { (bounds.bottom + totalExtraHeight).toDp() } Spacer(modifier = Modifier.height(contentBottom + 200.dp)) + // Reaction Toolbar (Above) + Box( + modifier = Modifier + .offset { IntOffset(0, (bounds.top - toolbarHeight - spacing).toInt().coerceAtLeast(0)) } + .onGloballyPositioned { toolbarHeight = it.size.height.toFloat() } + .fillMaxWidth() + .padding(horizontal = 16.dp), + contentAlignment = if (model.isMine) Alignment.CenterEnd else Alignment.CenterStart + ) { + ReactionToolbar( + onReaction = { reaction -> + viewModel.sendReaction(model.id, reaction) + selectedMessage = null + } + ) + } + + // Message Preview ChatMessage( model = model, modifier = Modifier @@ -492,38 +522,35 @@ fun ChatScreen( .padding(horizontal = 16.dp) ) - val menuOffset = if (showAbove) { - bounds.top - menuHeight - spacing - } else { - bounds.bottom + spacing - } - + // Action Menu (Below) Box( modifier = Modifier - .offset { IntOffset(0, menuOffset.toInt().coerceAtLeast(0)) } + .offset { IntOffset(0, (bounds.bottom + spacing).toInt()) } .onGloballyPositioned { menuHeight = it.size.height.toFloat() } .fillMaxWidth() .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 } - selectedMessage = null - } + ) } } } @@ -622,9 +649,41 @@ private fun ReplyPreview( } } +@Composable +private fun ReactionToolbar( + onReaction: (String) -> Unit +) { + val reactionEmojis = listOf("👍", "❤️", "😂", "😮", "😢", "😡", "🎉") + + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(24.dp), + shadowElevation = 1.dp + ) { + Row( + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + reactionEmojis.forEach { emoji -> + Text( + text = emoji, + modifier = Modifier + .clickable { onReaction(emoji) } + .padding(4.dp), + fontSize = 24.sp + ) + } + } + } +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun ContextMenu(onAction: (String) -> Unit) { +private fun ContextMenu( + onAction: (String) -> Unit +) { val menuItems = listOf( "Copy" to Res.drawable.ic_copy, "Reply" to Res.drawable.ic_reply @@ -633,6 +692,8 @@ private fun ContextMenu(onAction: (String) -> Unit) { DropdownMenuGroup( shapes = MenuDefaults.groupShape(1, 1), containerColor = MenuDefaults.groupVibrantContainerColor, + tonalElevation = 1.dp, + shadowElevation = 1.dp, modifier = Modifier.width(220.dp) ) { val itemCount = menuItems.size 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..f362db1 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 @@ -176,12 +179,13 @@ class MessageManager(private val nostr: Nostr) { val kind = Kind.fromStd(KindStandard.APPLICATION_SPECIFIC_DATA) val kTag = SingleLetterTag.lowercase(Alphabet.K) - // Get all DM events - val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm")) + // Get all rumors (DMs and Reactions) + val filter = Filter().kind(kind).customTags(kTag, listOf("14", "dm", "reaction")) val events = client?.database()?.query(filter)?.toVec() ?: return null // Collect rooms val roomsMap: MutableMap = mutableMapOf() + val lastDmTimestampMap: MutableMap = mutableMapOf() events .map { UnsignedEvent.fromJson(it.content()) } @@ -189,18 +193,41 @@ class MessageManager(private val nostr: Nostr) { .forEach { rumor -> val id = rumor.roomId() val isFromMe = rumor.author() == userPubkey + val isReaction = rumor.kind().asStd() == KindStandard.REACTION val existing = roomsMap[id] val createdAt = rumor.createdAt() - // If the room is new or the current rumor is newer than the existing one - if (existing == null || createdAt.asSecs() > existing.createdAt.asSecs()) { - // A room is "Ongoing" if it was already marked as such or if the current rumor is from the user - val isOngoing = (existing?.kind == RoomKind.Ongoing) || isFromMe + if (existing == null) { val room = Room.new(rumor = rumor, userPubkey = userPubkey, id = id) - roomsMap[id] = if (isOngoing) room.copy(kind = RoomKind.Ongoing) else room - } else if (isFromMe && existing.kind != RoomKind.Ongoing) { - // If it's an older rumor but sent by the user, mark the room as Ongoing - roomsMap[id] = existing.copy(kind = RoomKind.Ongoing) + // If the first event we see is a reaction, don't use it as lastMessage + roomsMap[id] = if (isReaction) { + room.copy(lastMessage = null) + } else { + lastDmTimestampMap[id] = createdAt.asSecs() + room + } + if (isFromMe) { + roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing) + } + } else { + // Update the overall room timestamp (for sorting) if this event is newer + if (createdAt.asSecs() > existing.createdAt.asSecs()) { + roomsMap[id] = roomsMap[id]!!.copy(createdAt = createdAt) + } + + // Update the last message content if this is a DM and it's newer than the last DM we've seen + if (!isReaction) { + val lastDmTs = lastDmTimestampMap[id] ?: 0uL + if (createdAt.asSecs() >= lastDmTs) { + lastDmTimestampMap[id] = createdAt.asSecs() + roomsMap[id] = roomsMap[id]!!.copy(lastMessage = rumor.content()) + } + } + + // If any event is from the user, mark the room as Ongoing + if (isFromMe && roomsMap[id]?.kind != RoomKind.Ongoing) { + roomsMap[id] = roomsMap[id]!!.copy(kind = RoomKind.Ongoing) + } } } @@ -348,4 +375,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..645b384 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,22 +277,24 @@ 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()) { - // Only update preview if message is newer (handles sync/late arrivals) + // Update timestamp for any newer event (DM or Reaction) + // But only update preview if it's a DM rooms[roomId] = existingRoom.copy( - lastMessage = event.content(), + lastMessage = if (isReaction) existingRoom.lastMessage else event.content(), createdAt = event.createdAt(), kind = newKind, - unreadCount = if (isFromMe) existingRoom.unreadCount else existingRoom.unreadCount + 1 + unreadCount = if (isFromMe || isReaction) 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) + } }