diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 14f30091f9..62dd6a15d7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -88,6 +88,7 @@ open class MessagesStateProvider : PreviewParameterProvider { ), aMessagesState(roomName = AsyncData.Success("A DM with a very looong name"), dmUserVerificationState = IdentityState.Verified), aMessagesState(roomName = AsyncData.Success("A DM with a very looong name"), dmUserVerificationState = IdentityState.VerificationViolation), + aMessagesState(successorRoom = SuccessorRoom(RoomId("!id:domain"), null)), ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt index 92fc0d8171..32638d7b8d 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.tests.testutils.lambda.lambdaError @@ -20,6 +21,7 @@ class FakeMessagesNavigator( private val onReportContentClickLambda: (eventId: EventId, senderId: UserId) -> Unit = { _, _ -> lambdaError() }, private val onEditPollClickLambda: (eventId: EventId) -> Unit = { _ -> lambdaError() }, private val onPreviewAttachmentLambda: (attachments: ImmutableList) -> Unit = { _ -> lambdaError() }, + private val onNavigateToRoomLambda: (roomId: RoomId) -> Unit = { _ -> lambdaError() } ) : MessagesNavigator { override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { onShowEventDebugInfoClickLambda(eventId, debugInfo) @@ -40,4 +42,8 @@ class FakeMessagesNavigator( override fun onPreviewAttachment(attachments: ImmutableList) { onPreviewAttachmentLambda(attachments) } + + override fun onNavigateToRoom(roomId: RoomId) { + onNavigateToRoomLambda(roomId) + } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index e42941da0d..2271f6a7d1 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -50,6 +50,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.media.MediaSource @@ -57,6 +58,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId @@ -1130,6 +1132,57 @@ class MessagesPresenterTest { } } + @Test + fun `present - room with successor room includes successor info in state`() = runTest { + val successorRoomId = RoomId("!successor:server.org") + val successorReason = "This room has been moved to a new location" + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + initialRoomInfo = aRoomInfo( + successorRoom = SuccessorRoom( + roomId = successorRoomId, + reason = successorReason + ) + ) + ), + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.successorRoom).isNotNull() + assertThat(initialState.successorRoom?.roomId).isEqualTo(successorRoomId) + assertThat(initialState.successorRoom?.reason).isEqualTo(successorReason) + } + } + + @Test + fun `present - room without successor room has null successor info in state`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + canRedactOwnResult = { Result.success(true) }, + canRedactOtherResult = { Result.success(true) }, + canUserJoinCallResult = { Result.success(true) }, + canUserPinUnpinResult = { Result.success(true) }, + initialRoomInfo = aRoomInfo(successorRoom = null) + ), + typingNoticeResult = { Result.success(Unit) }, + ) + val presenter = createMessagesPresenter(joinedRoom = room) + presenter.testWithLifecycleOwner { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.successorRoom).isNull() + } + } + @Test fun `present - when room is encrypted and a DM, the DM user's identity state is fetched onResume`() = runTest { val room = FakeJoinedRoom( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 4f6ac91890..592f7bb0f0 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -52,7 +52,9 @@ import io.element.android.features.messages.impl.timeline.components.receipt.aRe import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl import io.element.android.libraries.matrix.api.timeline.item.event.getDisplayName import io.element.android.libraries.matrix.api.user.MatrixUser @@ -65,6 +67,7 @@ import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParamsAndResult import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.assertNoNodeWithText import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.pressBack @@ -554,6 +557,36 @@ class MessagesViewTest { rule.onNodeWithText("This is a pinned message").performClick() eventsRecorder.assertSingle(TimelineEvents.FocusOnEvent(AN_EVENT_ID, debounce = FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS.milliseconds)) } + + @Test + fun `clicking on successor room button emits expected event`() { + val eventsRecorder = EventsRecorder() + val successorRoomId = RoomId("!successor:server.org") + val state = aMessagesState( + successorRoom = SuccessorRoom( + roomId = successorRoomId, + reason = "This room has been upgraded" + ), + timelineState = aTimelineState(eventSink = eventsRecorder) + ) + rule.setMessagesView(state = state) + val text = rule.activity.getString(R.string.screen_room_timeline_tombstoned_room_action) + // The bottomsheet subcompose seems to make the node to appear twice + rule.onAllNodesWithText(text).onFirst().performClick() + eventsRecorder.assertSingle(TimelineEvents.NavigateToRoom(successorRoomId)) + } + + @Test + fun `no banner shown when there is no successor room`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + val state = aMessagesState( + successorRoom = null, + eventSink = eventsRecorder + ) + rule.setMessagesView(state = state) + rule.assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_message) + rule.assertNoNodeWithText(R.string.screen_room_timeline_tombstoned_room_action) + } } private fun AndroidComposeTestRule.setMessagesView( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index a95cf4f31f..2cc7d9764a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.room.RoomMembersState +import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.Timeline @@ -64,6 +65,7 @@ import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf @@ -80,7 +82,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @Suppress("LargeClass") -@OptIn(ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) class TimelinePresenterTest { @get:Rule val warmUpRule = WarmUpRule() @@ -705,6 +707,73 @@ class TimelinePresenterTest { } } + @Test + fun `present - timeline room info includes predecessor room when room has predecessor`() = runTest { + val predecessorRoomId = RoomId("!predecessor:server.org") + val predecessorEventId = EventId("\$predecessorEvent:server.org") + val predecessorRoom = PredecessorRoom( + roomId = predecessorRoomId, + lastEventId = predecessorEventId + ) + + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + predecessorRoomResult = { predecessorRoom } + ), + ) + + val presenter = createTimelinePresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.timelineRoomInfo.predecessorRoom).isNotNull() + assertThat(initialState.timelineRoomInfo.predecessorRoom?.roomId).isEqualTo(predecessorRoomId) + assertThat(initialState.timelineRoomInfo.predecessorRoom?.lastEventId).isEqualTo(predecessorEventId) + } + } + + @Test + fun `present - timeline room info no predecessor`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + predecessorRoomResult = { null } + ), + ) + val presenter = createTimelinePresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitFirstItem() + assertThat(initialState.timelineRoomInfo.predecessorRoom).isNull() + } + } + + @Test + fun `present - timeline event navigate to room`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + canUserSendMessageResult = { _, _ -> Result.success(true) }, + ), + ) + val onNavigateToRoomLambda = lambdaRecorder {} + val navigator = FakeMessagesNavigator( + onNavigateToRoomLambda = onNavigateToRoomLambda + ) + val presenter = createTimelinePresenter(room = room, messagesNavigator = navigator) + presenter.test { + val initialState = awaitFirstItem() + initialState.eventSink(TimelineEvents.NavigateToRoom(A_ROOM_ID)) + assert(onNavigateToRoomLambda) + .isCalledOnce() + .with( + value(A_ROOM_ID) + ) + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { return awaitItem() } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt index ae9aee8736..c7e1d3e143 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.draft.ComposerDraft import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -66,6 +67,7 @@ class FakeBaseRoom( private val getRoomVisibilityResult: () -> Result = { lambdaError() }, private val forgetResult: () -> Result = { lambdaError() }, private val reportRoomResult: (String?) -> Result = { lambdaError() }, + private val predecessorRoomResult: () -> PredecessorRoom? = { null }, ) : BaseRoom { private val _roomInfoFlow: MutableStateFlow = MutableStateFlow(initialRoomInfo) override val roomInfoFlow: StateFlow = _roomInfoFlow @@ -215,6 +217,8 @@ class FakeBaseRoom( } override suspend fun reportRoom(reason: String?) = reportRoomResult(reason) + + override fun predecessorRoom(): PredecessorRoom? = predecessorRoomResult() } fun defaultRoomPowerLevels() = RoomPowerLevels( diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt index 440af51676..55371a960d 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import io.element.android.libraries.ui.strings.CommonStrings import org.junit.rules.TestRule @@ -54,3 +55,8 @@ fun AndroidComposeTestRule.pressBackKey() { fun SemanticsNodeInteractionsProvider.pressTag(tag: String) { onNode(hasTestTag(tag)).performClick() } + +fun AndroidComposeTestRule.assertNoNodeWithText(@StringRes res: Int) { + val text = activity.getString(res) + onNodeWithText(text).assertDoesNotExist() +}