Show a Message not found placeholder when a replied to message cannot be loaded (#7376)

* Show a Message not found placeholder when a replied to message cannot be loaded

When the replied to event could not be fetched, the reply preview rendered the
raw SDK error string in critical styling, and tapping it emitted a focus
request that could never succeed, so the user got a dead end dialog.

Render the localised error_message_not_found string in the neutral italic style
already used for redacted and undecryptable replies, and drop the click handler
for that case. The existing key is reused rather than adding a new one; a "you
do not have access" wording was rejected because this state covers any failure
to load the event, not only a permission problem.

* Update screenshots

* Trigger CI after recording screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty
2026-08-10 11:59:37 +02:00
committed by GitHub
5 changed files with 77 additions and 12 deletions
@@ -804,12 +804,12 @@ private fun MessageEventBubbleContent(
.padding(top = topPadding, start = 8.dp, end = 8.dp) .padding(top = topPadding, start = 8.dp, end = 8.dp)
.clip(shape) .clip(shape)
val talkbackCompatModifier = if (isTalkbackActive()) { val talkbackCompatModifier = when {
// Use z-index to make the replied to text being read after the message // Use z-index to make the replied to text being read after the message
// Usually, you'd use traversalIndex for that, but it's not working for some reason // Usually, you'd use traversalIndex for that, but it's not working for some reason
inReplyToModifier.zIndex(1f) isTalkbackActive() -> inReplyToModifier.zIndex(1f)
} else { inReplyTo is InReplyToDetails.Error -> inReplyToModifier
inReplyToModifier.clickable(onClick = inReplyToClick) else -> inReplyToModifier.clickable(onClick = inReplyToClick)
} }
val contentHasError = currentContentValidationState.hasError() val contentHasError = currentContentValidationState.hasError()
@@ -13,8 +13,12 @@ package io.element.android.features.messages.impl.timeline
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToIndex import androidx.compose.ui.test.performScrollToIndex
import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.test.v2.runAndroidComposeUiTest
@@ -29,8 +33,14 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
@@ -168,6 +178,61 @@ class TimelineViewTest : RobolectricTest() {
eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog) eventsRecorder.assertSingle(TimelineEvent.HideShieldDialog)
} }
@Test
fun `clicking on a reply to a loaded event emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>()
setTimelineView(
state = aTimelineState(
timelineItems = persistentListOf(
aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = InReplyToDetails.Ready(
eventId = AN_EVENT_ID,
senderId = A_USER_ID,
senderProfile = aProfileDetailsReady(),
eventContent = MessageContent(
body = "Replied message",
inReplyTo = null,
isEdited = false,
threadInfo = null,
type = TextMessageType("Replied message", null),
),
textContent = "Replied message",
),
),
),
eventSink = eventsRecorder,
),
)
eventsRecorder.clear()
onAllNodesWithText("Replied message").onFirst().performClick()
eventsRecorder.assertSingle(TimelineEvent.FocusOnEvent(AN_EVENT_ID))
}
@Test
fun `a reply to an event which could not be loaded renders a placeholder and does not emit any Event when clicked`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<TimelineEvent>()
setTimelineView(
state = aTimelineState(
timelineItems = persistentListOf(
aTimelineItemEvent(
content = aTimelineItemTextContent(),
inReplyTo = InReplyToDetails.Error(eventId = AN_EVENT_ID, message = "Failed to fetch the event"),
),
),
eventSink = eventsRecorder,
),
onMessageClick = {},
)
eventsRecorder.assertSingle(TimelineEvent.OnScrollFinished(firstIndex = 0))
eventsRecorder.clear()
onNodeWithText("Failed to fetch the event").assertDoesNotExist()
onNodeWithText(activity!!.getString(CommonStrings.error_message_not_found)).assertIsDisplayed().performClick()
eventsRecorder.assertEmpty()
}
@Ignore( @Ignore(
"performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." + "performScrollToIndex in compose tests no longer sets LazyListState.isScrollInProgress to true, so the LoadMore event is not emitted." +
"This needs to be reworked to use a different approach to check the LoadMore event was emitted." "This needs to be reworked to use a different approach to check the LoadMore event was emitted."
@@ -77,7 +77,7 @@ fun InReplyToView(
else -> ReplyToLoadingContent(modifier = modifier) else -> ReplyToLoadingContent(modifier = modifier)
} }
is InReplyToDetails.Error -> is InReplyToDetails.Error ->
ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier) ReplyToErrorContent(maxLines = maxLines, modifier = modifier)
is InReplyToDetails.Loading -> is InReplyToDetails.Loading ->
ReplyToLoadingContent(modifier = modifier) ReplyToLoadingContent(modifier = modifier)
} }
@@ -150,7 +150,6 @@ private fun ReplyToLoadingContent(
@Composable @Composable
private fun ReplyToErrorContent( private fun ReplyToErrorContent(
data: InReplyToDetails.Error,
maxLines: Int, maxLines: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@@ -161,9 +160,10 @@ private fun ReplyToErrorContent(
.padding(paddings) .padding(paddings)
) { ) {
Text( Text(
text = data.message, text = stringResource(id = CommonStrings.error_message_not_found),
style = ElementTheme.typography.fontBodyMdRegular, style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textCriticalPrimary, fontStyle = FontStyle.Italic,
color = ElementTheme.colors.textSecondary,
maxLines = maxLines, maxLines = maxLines,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )