From 898b6683b1b7bae59c86e94bb41c26ffc23b4c8c Mon Sep 17 00:00:00 2001 From: Shishupal Date: Wed, 18 Mar 2026 06:13:18 +0530 Subject: [PATCH] Add message reaction to chat - long press bubble to react emoji --- .../compose/jetchat/MessageReactionTest.kt | 165 ++++++++++++++++++ .../jetchat/conversation/Conversation.kt | 107 +++++++++++- .../conversation/ConversationUiState.kt | 8 + 3 files changed, 277 insertions(+), 3 deletions(-) create mode 100644 Jetchat/app/src/androidTest/java/com/example/compose/jetchat/MessageReactionTest.kt diff --git a/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/MessageReactionTest.kt b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/MessageReactionTest.kt new file mode 100644 index 0000000000..5e762794f6 --- /dev/null +++ b/Jetchat/app/src/androidTest/java/com/example/compose/jetchat/MessageReactionTest.kt @@ -0,0 +1,165 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.compose.jetchat + +import androidx.activity.ComponentActivity +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.compose.jetchat.conversation.ChatItemBubble +import com.example.compose.jetchat.conversation.ConversationContent +import com.example.compose.jetchat.conversation.ConversationTestTag +import com.example.compose.jetchat.conversation.ConversationUiState +import com.example.compose.jetchat.conversation.EmojiPickerDialog +import com.example.compose.jetchat.conversation.Message +import com.example.compose.jetchat.conversation.Messages +import com.example.compose.jetchat.data.exampleUiState +import com.example.compose.jetchat.data.initialMessages +import com.example.compose.jetchat.theme.JetchatTheme +import junit.framework.TestCase.assertEquals +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MessageReactionTest { + + @get:Rule + val composeTestRule = createComposeRule() + + //Test 1 : Emoji picker dialog shows with correct test tag + @Test + fun emojiPickerDialog_isDisplayed_withCorrectTag(){ + composeTestRule.setContent { + JetchatTheme { + EmojiPickerDialog(onEmojiSelected = {}, onDismiss = {}) + } + } + composeTestRule.onNodeWithTag("reaction_picker").assertIsDisplayed() + + } + + //Test 2 : Emoji picker show all emoji options row + @Test + fun emojiPickerDialog_showEmojiRow(){ + composeTestRule.setContent { + JetchatTheme { + EmojiPickerDialog(onEmojiSelected = {}, onDismiss = {}) + } + } + composeTestRule.onNodeWithTag("reaction_emoji_row").assertIsDisplayed() + composeTestRule.onNodeWithTag("emoji_option_👍").assertIsDisplayed() + } + + //Test 3 : Tapping emoji triggers callback with correct value + @Test + fun emojiPickerDialog_selectEmoji_triggersCallback(){ + var result : String? = null + + composeTestRule.setContent { + JetchatTheme { + EmojiPickerDialog(onEmojiSelected = { result = it}, onDismiss = {}) + } + } + composeTestRule.onNodeWithTag("emoji_option_👍").performClick() + assertEquals("👍",result) + } + + //Test 4 : Long press on message bubble shows emoji picker + @Test + fun chatBubble_longPress_showsEmojiPicker(){ + composeTestRule.setContent { + JetchatTheme { + ChatItemBubble( + message = Message(author = "Taylor", content = "Hello!", timestamp = "8:00 PM"), + isUserMe = false, + authorClicked = {}, + onReactionAdded = {}, + ) + } + } + composeTestRule.onNodeWithTag("Hello!").performTouchInput { longClick() } + composeTestRule.onNodeWithTag("reaction_picker").assertIsDisplayed() + } + + //Test 5 : Reaction chip appears when message has reaction + @Test + fun chatBubble_withReaction_showsReactionClip(){ + composeTestRule.setContent { + JetchatTheme { + ChatItemBubble( + message = Message( + author = "Taylor", + content = "Hello!", + timestamp = "8:00 PM", + reactions = mapOf("👍" to 1) + ), + isUserMe = false, + authorClicked = {}, + ) + } + } + composeTestRule.onNodeWithTag("reaction_chip_👍").assertIsDisplayed() + } + + //Test 6 : addReaction increments count correctly + @Test + fun conversationUiState_addReaction_incrementsCount(){ + val state = ConversationUiState( + channelName = "#test", + channelMembers = 2 , + initialMessages = listOf( + Message(author = "me", content = "Hi", timestamp = "8:00 PM") + ) + ) + state.addReaction(0, "👍") + assertEquals(1, state.messages[0].reactions["👍"]) + state.addReaction(0, "👍") + assertEquals(2, state.messages[0].reactions["👍"]) + } + + //Test 7 : Multiple different reactions tacked separately + @Test + fun conversationUiState_multipleReaction_trackedSeparately(){ + val state = ConversationUiState( + channelName = "#test", + channelMembers = 2 , + initialMessages = listOf( + Message(author = "me", content = "Hi", timestamp = "8:00 PM") + ) + ) + state.addReaction(0, "👍") + state.addReaction(0, "❤️") + state.addReaction(0, "👍") + assertEquals(2, state.messages[0].reactions["👍"]) + assertEquals(1, state.messages[0].reactions["❤️"]) + } + + +} \ No newline at end of file diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt index 8f5290750b..a30dbbae74 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/Conversation.kt @@ -24,15 +24,20 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding @@ -48,6 +53,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -57,6 +63,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberTopAppBarState @@ -66,6 +73,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -76,6 +84,7 @@ import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.platform.LocalDensity @@ -197,6 +206,7 @@ fun ConversationContent( navigateToProfile = navigateToProfile, modifier = Modifier.weight(1f), scrollState = scrollState, + onReactionAdded = {index, emoji -> uiState.addReaction(index,emoji)} ) UserInput( onMessageSent = { content -> @@ -277,7 +287,7 @@ fun ChannelNameBar( const val ConversationTestTag = "ConversationTestTag" @Composable -fun Messages(messages: List, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier) { +fun Messages(messages: List, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier,onReactionAdded: (Int,String)-> Unit = {_, _ -> }) { val scope = rememberCoroutineScope() Box(modifier = modifier) { @@ -314,6 +324,7 @@ fun Messages(messages: List, navigateToProfile: (String) -> Unit, scrol isUserMe = content.author == authorMe, isFirstMessageByAuthor = isFirstMessageByAuthor, isLastMessageByAuthor = isLastMessageByAuthor, + onReactionAdded = { emoji -> onReactionAdded(index, emoji) }, ) } } @@ -353,6 +364,7 @@ fun Message( isUserMe: Boolean, isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean, + onReactionAdded: (String) -> Unit = {}, ) { val borderColor = if (isUserMe) { MaterialTheme.colorScheme.primary @@ -387,6 +399,7 @@ fun Message( isFirstMessageByAuthor = isFirstMessageByAuthor, isLastMessageByAuthor = isLastMessageByAuthor, authorClicked = onAuthorClick, + onReactionAdded = onReactionAdded, modifier = Modifier .padding(end = 16.dp) .weight(1f), @@ -401,13 +414,14 @@ fun AuthorAndTextMessage( isFirstMessageByAuthor: Boolean, isLastMessageByAuthor: Boolean, authorClicked: (String) -> Unit, + onReactionAdded: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { if (isLastMessageByAuthor) { AuthorNameTimestamp(msg) } - ChatItemBubble(msg, isUserMe, authorClicked = authorClicked) + ChatItemBubble(msg, isUserMe, authorClicked = authorClicked, onReactionAdded = onReactionAdded) if (isFirstMessageByAuthor) { // Last bubble before next author Spacer(modifier = Modifier.height(8.dp)) @@ -470,7 +484,12 @@ private fun RowScope.DayHeaderLine() { } @Composable -fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) { +fun ChatItemBubble( + message: Message, + isUserMe: Boolean, + authorClicked: (String) -> Unit, + onReactionAdded: (String) -> Unit = {}, +) { val backgroundBubbleColor = if (isUserMe) { MaterialTheme.colorScheme.primary @@ -478,10 +497,16 @@ fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) MaterialTheme.colorScheme.surfaceVariant } + var showEmojiPicker by rememberSaveable { mutableStateOf(false) } + Column { Surface( color = backgroundBubbleColor, shape = ChatBubbleShape, + modifier = Modifier.combinedClickable( + onLongClick = {showEmojiPicker = true}, + onClick = {}, + ) ) { ClickableMessage( message = message, @@ -504,9 +529,85 @@ fun ChatItemBubble(message: Message, isUserMe: Boolean, authorClicked: (String) ) } } + + // Show reaction chips if any reactions exist + if(message.reactions.isNotEmpty()){ + FlowRow( + modifier = Modifier + .padding(top = 4.dp) + .testTag("reaction_row"), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + message.reactions.forEach { (emoji, count) -> + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(12.dp), + modifier = Modifier.testTag("reaction_chip_$emoji") + ) { + Text( + text = "$emoji $count", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + } + + if(showEmojiPicker){ + EmojiPickerDialog( + onEmojiSelected = { emoji -> + onReactionAdded(emoji) + showEmojiPicker = false + }, + onDismiss = { + showEmojiPicker = false + } + ) } + } + +val ReactionEmojis = listOf("👍", "❤️", "😂", "😮", "😥", "🙏") + +@Composable +fun EmojiPickerDialog( + onEmojiSelected: (String) -> Unit, + onDismiss: () -> Unit +){ + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag("reaction_picker"), + title = { Text("React to message") }, + text = { + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxWidth() + .testTag("reaction_emoji_row") + ) { + ReactionEmojis.forEach { emoji -> + Text( + text = emoji, + modifier = Modifier + .clickable{ onEmojiSelected(emoji) } + .padding(8.dp) + .testTag("emoji_option_$emoji"), + style = MaterialTheme.typography.headlineMedium + ) + + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + + @Composable fun ClickableMessage(message: Message, isUserMe: Boolean, authorClicked: (String) -> Unit) { val uriHandler = LocalUriHandler.current diff --git a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt index b2ac479e95..583ffbcaad 100644 --- a/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt +++ b/Jetchat/app/src/main/java/com/example/compose/jetchat/conversation/ConversationUiState.kt @@ -27,6 +27,13 @@ class ConversationUiState(val channelName: String, val channelMembers: Int, init fun addMessage(msg: Message) { _messages.add(0, msg) // Add to the beginning of the list } + + fun addReaction(messageIndex: Int, emoji: String){ + val msg = _messages.getOrNull(messageIndex) ?: return + val updatedReactions = msg.reactions.toMutableMap() + updatedReactions[emoji] = (updatedReactions[emoji] ?: 0) + 1 + _messages[messageIndex] = msg.copy(reactions = updatedReactions) + } } @Immutable @@ -36,4 +43,5 @@ data class Message( val timestamp: String, val image: Int? = null, val authorImage: Int = if (author == "me") R.drawable.ali else R.drawable.someone_else, + val reactions: Map = emptyMap(), )