Skip to content
Merged
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
Expand Up @@ -34,10 +34,12 @@ import io.getstream.chat.android.e2e.test.uiautomator.device
import io.getstream.chat.android.e2e.test.uiautomator.findObjects
import io.getstream.chat.android.e2e.test.uiautomator.isDisplayed
import io.getstream.chat.android.e2e.test.uiautomator.longPress
import io.getstream.chat.android.e2e.test.uiautomator.sleep
import io.getstream.chat.android.e2e.test.uiautomator.swipeDown
import io.getstream.chat.android.e2e.test.uiautomator.swipeUp
import io.getstream.chat.android.e2e.test.uiautomator.typeText
import io.getstream.chat.android.e2e.test.uiautomator.wait
import io.getstream.chat.android.e2e.test.uiautomator.waitDisplayed
import io.getstream.chat.android.e2e.test.uiautomator.waitToAppear
import io.getstream.chat.android.e2e.test.uiautomator.waitToAppearAndClick
import io.getstream.chat.android.e2e.test.uiautomator.waitToAppearBottomUp
Expand Down Expand Up @@ -71,7 +73,7 @@ class UserRobot {
}

fun openChannel(channelCellIndex: Int = 0): UserRobot {
ChannelListPage.ChannelList.channels.wait().findObjects()[channelCellIndex].click()
ChannelListPage.ChannelList.channels.waitToAppearAndClick(withIndex = channelCellIndex)
return this
}

Expand Down Expand Up @@ -279,7 +281,16 @@ class UserRobot {
}

fun tapOnQuotedMessage(messageCellIndex: Int = 0): UserRobot {
Message.quotedMessage.waitToAppearAndClick()
// A tap that lands while the list is still moving is cancelled by the touch slop and
// never reaches the click handler, so verify the jump moved the quote out of the
// viewport and tap again when it did not. Does not fail on its own: the assertion
// that follows reports a jump that never happened.
repeat(3) {
Message.quotedMessage.waitToAppearAndClick()
if (!Message.quotedMessage.waitToDisappear(timeOutMillis = 3_000).isDisplayed()) {
return this
}
}
return this
}

Expand Down Expand Up @@ -385,6 +396,35 @@ class UserRobot {
return this
}

/**
* Scrolls the message list up one page at a time until the message with [messageText] is
* displayed and clear of the top edge of the list, giving up after [maxScrolls] pages. The
* first sighting is not enough: a message still clipped by the top edge after the scroll
* settles has no laid-out text to long press, so it counts as not reached yet. Only the top
* edge matters: scrolling up moves content downwards, so it is the edge the target enters
* from, and a text clipped by the bottom edge is laid out and can be pressed. Does not fail
* on its own: the interaction that follows reports the missing message.
*/
fun scrollMessageListUpToMessage(messageText: String, maxScrolls: Int = 10): UserRobot {
val message = Message.text
.text(messageText)
.hasAncestor(MessageList.messages)
val listTop = MessageList.messageList.waitToAppear().visibleBounds.top
repeat(maxScrolls) {
if (message.waitDisplayed(timeOutMillis = 1_000)) {
sleep(500) // let the fling settle before trusting the bounds
val fullyVisible = runCatching {
message.findObjects().firstOrNull()?.visibleBounds?.let { it.top > listTop } == true
}.getOrDefault(false)
if (fullyVisible) {
return this
}
}
scrollMessageListUp(times = 1)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return this
}

fun swipeMessage(messageCellIndex: Int = 0): UserRobot {
val percent = 0.5f
val message = MessageList.messages.waitToAppearBottomUp(withIndex = messageCellIndex)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import io.getstream.chat.android.e2e.test.mockserver.AttachmentType
import io.getstream.chat.android.e2e.test.uiautomator.appContext
import io.qameta.allure.kotlin.Allure.step
import io.qameta.allure.kotlin.AllureId
import org.junit.Ignore
import org.junit.Test
import io.getstream.chat.android.ui.common.R as UiCommonR

Expand Down Expand Up @@ -363,7 +362,6 @@ class QuotedReplyTests : StreamTestCase() {
}

@AllureId("5892")
@Ignore("https://linear.app/stream/issue/AND-1332")
@Test
fun test_quotedReplyNotInList_whenUserAddsQuotedReply_InThread() {
step("GIVEN user opens the channel") {
Expand All @@ -373,7 +371,7 @@ class QuotedReplyTests : StreamTestCase() {
step("WHEN user adds a quoted reply to message in thread") {
userRobot
.openThread()
.scrollMessageListUp(times = 8)
.scrollMessageListUpToMessage(firstMessage)
.quoteMessage(quoteReply, quotedMessageText = firstMessage)
}
step("THEN user observes the quote reply in thread") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
Expand Down Expand Up @@ -75,6 +76,7 @@ import io.getstream.chat.android.ui.common.state.messages.list.Typing
import io.getstream.chat.android.ui.common.state.messages.list.UnreadSeparatorItemState
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.math.abs

Expand Down Expand Up @@ -346,6 +348,17 @@ private fun MessageListState.getVerticalArrangement(
): Arrangement.Vertical =
if (parentMessageId != null) threadsVerticalArrangement else messagesVerticalArrangement

/**
* Scrolls to the focused item at [focusedItemIndex], or returns right away when there is none
* (index -1). A scroll that is still in progress is waited out first instead of skipping the
* jump: the focused state is consumed either way, so a skipped jump would never be retried.
*/
internal suspend fun LazyListState.scrollToFocusedItem(focusedItemIndex: Int, offset: Int) {
if (focusedItemIndex == -1) return
snapshotFlow { isScrollInProgress }.first { inProgress -> !inProgress }
animateScrollToItem(focusedItemIndex, offset)
}

/**
* Represents the default scrolling behavior and UI for [Messages], based on the state of messages and the scroll state.
*
Expand Down Expand Up @@ -385,11 +398,7 @@ internal fun BoxScope.DefaultMessagesHelperContent(
val offset = messagesLazyListState.focusedMessageOffset

LaunchedEffect(focusedItemIndex, offset) {
if (focusedItemIndex != -1 &&
!lazyListState.isScrollInProgress
) {
lazyListState.animateScrollToItem(focusedItemIndex, offset)
}
lazyListState.scrollToFocusedItem(focusedItemIndex, offset)
}

// Keep track of the last new message state that triggered a scroll to bottom.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
*
* 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 io.getstream.chat.android.compose.ui.messages.list

import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config

@RunWith(AndroidJUnit4::class)
@Config(sdk = [33])
internal class ScrollToFocusedItemTest {

@get:Rule
val composeTestRule = createComposeRule()

private lateinit var listState: LazyListState
private lateinit var scope: CoroutineScope

private fun setListContent() {
composeTestRule.setContent {
listState = rememberLazyListState()
scope = rememberCoroutineScope()
LazyColumn(state = listState, modifier = Modifier.size(200.dp)) {
items(count = 200) {
Box(modifier = Modifier.size(50.dp))
}
}
}
}

@Test
fun `given no focused item, the list does not scroll`() {
setListContent()

val jump = scrollToFocusedItemAsync(focusedItemIndex = -1)

composeTestRule.waitUntil(timeoutMillis = 5_000) { jump.isCompleted }
assertEquals(0, listState.firstVisibleItemIndex)
}

@Test
fun `given an idle list, scrolls to the focused item`() {
setListContent()

val jump = scrollToFocusedItemAsync(focusedItemIndex = 100)

composeTestRule.waitUntil(timeoutMillis = 5_000) { jump.isCompleted }
assertEquals(100, listState.firstVisibleItemIndex)
}

@Test
fun `given a scroll in progress, waits it out and then scrolls to the focused item`() {
Comment thread
gpunto marked this conversation as resolved.
setListContent()
// Pause the clock so the animation genuinely overlaps the jump. With the clock
// auto-advancing, runOnIdle would finish the animation before the jump even starts and
// the test would pass without exercising the wait. Assertions use runOnUiThread because
// runOnIdle cannot settle while the clock is paused mid-animation.
composeTestRule.mainClock.autoAdvance = false
composeTestRule.runOnUiThread {
scope.launch {
listState.animateScrollBy(value = 1_000f, animationSpec = tween(durationMillis = 2_000))
}
}
composeTestRule.mainClock.advanceTimeBy(500)
composeTestRule.runOnUiThread { assertTrue(listState.isScrollInProgress) }

val jump = composeTestRule.runOnUiThread {
scope.async { listState.scrollToFocusedItem(focusedItemIndex = 100, offset = 0) }
}

composeTestRule.mainClock.advanceTimeBy(500)
composeTestRule.runOnUiThread {
assertTrue(listState.isScrollInProgress)
assertFalse(jump.isCompleted)
assertTrue(listState.firstVisibleItemIndex < 100)
}

composeTestRule.mainClock.autoAdvance = true
composeTestRule.waitUntil(timeoutMillis = 5_000) { jump.isCompleted }
assertEquals(100, listState.firstVisibleItemIndex)
}

private fun scrollToFocusedItemAsync(focusedItemIndex: Int): Deferred<Unit> =
composeTestRule.runOnIdle {
scope.async {
listState.scrollToFocusedItem(focusedItemIndex = focusedItemIndex, offset = 0)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ public fun BySelector.waitToAppearAndClick(timeOutMillis: Long = defaultTimeout)
}
}

/**
* Waits up to [timeOutMillis] for objects matching this selector and clicks the one at
* [withIndex]. When the click lands on a node that went stale between the find and the click
* (e.g. because the containing list refreshed), the object is re-found and the click retried
* until the timeout, after which the last [StaleObjectException] escapes. A retry started just
* before the deadline is granted one final poll interval past it, so the last re-find is a
* real attempt instead of an immediate timeout.
*
* @param withIndex The zero-based index of the object to click.
* @param timeOutMillis Maximum time to wait before failing.
* @throws IllegalStateException when the timeout elapses without enough matching objects.
*/
public fun BySelector.waitToAppearAndClick(withIndex: Int, timeOutMillis: Long = defaultTimeout) {
val endTime = System.currentTimeMillis() + timeOutMillis
while (true) {
try {
waitToAppear(withIndex, maxOf(endTime - System.currentTimeMillis(), POLL_INTERVAL_MILLIS)).click()
return
} catch (e: StaleObjectException) {
if (System.currentTimeMillis() >= endTime) throw e
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

/**
* Waits up to [timeOutMillis] for objects matching this selector and returns the one at [withIndex].
*
Expand Down
Loading