Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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() }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test uses onNodeWithTag("Hello!") to find the message composable. However, no testTag with this value is set, which will cause the test to fail.

To correctly find the composable, you should use onNodeWithText("Hello!") instead. The long press gesture will be performed on the correct node and propagate to the parent Surface that handles it.

Suggested change
composeTestRule.onNodeWithTag("Hello!").performTouchInput { longClick() }
composeTestRule.onNodeWithText("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["❤️"])
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -197,6 +206,7 @@ fun ConversationContent(
navigateToProfile = navigateToProfile,
modifier = Modifier.weight(1f),
scrollState = scrollState,
onReactionAdded = {index, emoji -> uiState.addReaction(index,emoji)}
)
UserInput(
onMessageSent = { content ->
Expand Down Expand Up @@ -277,7 +287,7 @@ fun ChannelNameBar(
const val ConversationTestTag = "ConversationTestTag"

@Composable
fun Messages(messages: List<Message>, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier) {
fun Messages(messages: List<Message>, navigateToProfile: (String) -> Unit, scrollState: LazyListState, modifier: Modifier = Modifier,onReactionAdded: (Int,String)-> Unit = {_, _ -> }) {
val scope = rememberCoroutineScope()
Box(modifier = modifier) {

Expand Down Expand Up @@ -314,6 +324,7 @@ fun Messages(messages: List<Message>, navigateToProfile: (String) -> Unit, scrol
isUserMe = content.author == authorMe,
isFirstMessageByAuthor = isFirstMessageByAuthor,
isLastMessageByAuthor = isLastMessageByAuthor,
onReactionAdded = { emoji -> onReactionAdded(index, emoji) },
)
}
}
Expand Down Expand Up @@ -353,6 +364,7 @@ fun Message(
isUserMe: Boolean,
isFirstMessageByAuthor: Boolean,
isLastMessageByAuthor: Boolean,
onReactionAdded: (String) -> Unit = {},
) {
val borderColor = if (isUserMe) {
MaterialTheme.colorScheme.primary
Expand Down Expand Up @@ -387,6 +399,7 @@ fun Message(
isFirstMessageByAuthor = isFirstMessageByAuthor,
isLastMessageByAuthor = isLastMessageByAuthor,
authorClicked = onAuthorClick,
onReactionAdded = onReactionAdded,
modifier = Modifier
.padding(end = 16.dp)
.weight(1f),
Expand All @@ -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))
Expand Down Expand Up @@ -470,18 +484,29 @@ 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
} else {
MaterialTheme.colorScheme.surfaceVariant
}

var showEmojiPicker by rememberSaveable { mutableStateOf(false) }

Column {
Surface(
color = backgroundBubbleColor,
shape = ChatBubbleShape,
modifier = Modifier.combinedClickable(
onLongClick = {showEmojiPicker = true},
onClick = {},
)
Comment on lines +506 to +509
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Using combinedClickable with an empty onClick lambda consumes tap events. This prevents the ClickableText child composable from handling its own click events, such as for opening links or viewing profiles, which breaks existing functionality.

To fix this, you should use Modifier.pointerInput with detectTapGestures, specifying only the onLongPress handler. This will correctly handle the long press for reactions while allowing tap gestures to be processed by child composables.

            modifier = Modifier.pointerInput(Unit) {
                detectTapGestures(onLongPress = {
                    showEmojiPicker = true
                })
            }

) {
ClickableMessage(
message = message,
Expand All @@ -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
Expand Down
Loading