From b31dad4b26441b91a5ae5e7fc78d0f14940f4915 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Mon, 25 May 2026 11:31:53 +0300 Subject: [PATCH] Do not show membership/profile events in public rooms (#6360) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Filter some membership/profile/topic events in public rooms: don't display join and leave membership events in publicly joinable rooms, and hide display name and avatar url changes in non encrypted and publicly joinable rooms. * Add empty day post-processing to the timeline based on bxdxnn's code, tweaked. --------- Co-authored-by: Jorge Martín --- .../factories/TimelineItemsFactory.kt | 26 ++- .../factories/TimelineItemsFactoryTest.kt | 160 ++++++++++++++++++ .../matrix/impl/timeline/RustTimeline.kt | 17 +- .../RoomBeginningPostProcessor.kt | 18 +- .../impl/timeline/postprocessor/Fixtures.kt | 9 + .../RoomBeginningPostProcessorTest.kt | 117 ++++++++++++- 6 files changed, 339 insertions(+), 8 deletions(-) create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt index 7b369fe6b7..dc8bdddc92 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactory.kt @@ -16,6 +16,7 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemDaySeparatorModel import io.element.android.libraries.androidutils.diff.DiffCacheUpdater import io.element.android.libraries.androidutils.diff.MutableListDiffCache import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -96,7 +97,8 @@ class TimelineItemsFactory( } } val result = timelineItemGrouper.group(newTimelineItemStates).toImmutableList() - this._timelineItems.emit(result) + val filteredResult = filterEmptyDaySeparators(result) + this._timelineItems.emit(filteredResult) } private suspend fun buildAndCacheItem( @@ -114,3 +116,25 @@ class TimelineItemsFactory( return timelineItem } } + +// Remove day separators for days with no events after the client-side event filtering +internal fun filterEmptyDaySeparators(items: List): ImmutableList { + return buildList { + var hasEventBefore = false + for (item in items) { + when (item) { + is TimelineItem.Event, is TimelineItem.GroupedEvents -> { + hasEventBefore = true + add(item) + } + is TimelineItem.Virtual if item.model is TimelineItemDaySeparatorModel -> { + if (hasEventBefore) { + add(item) + } + hasEventBefore = false + } + else -> add(item) + } + } + }.toImmutableList() +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt new file mode 100644 index 0000000000..8df8d34f56 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/TimelineItemsFactoryTest.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.messages.impl.timeline.factories + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.fixtures.aMessageEvent +import io.element.android.features.messages.impl.timeline.aTimelineItemDebugInfo +import io.element.android.features.messages.impl.timeline.aTimelineItemReactions +import io.element.android.features.messages.impl.timeline.model.ReadReceiptData +import io.element.android.features.messages.impl.timeline.model.TimelineItem +import io.element.android.features.messages.impl.timeline.model.TimelineItemReadReceipts +import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemReadMarkerModel +import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails +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.test.core.FakeSendHandle +import kotlinx.collections.immutable.toImmutableList +import org.junit.Test + +class TimelineItemsFactoryTest { + private val anEvent = TimelineItem.Event( + id = UniqueId("event"), + eventId = AN_EVENT_ID, + senderId = A_USER_ID, + senderAvatar = anAvatarData(), + senderProfile = ProfileDetails.Ready(displayName = "User", displayNameAmbiguous = false, avatarUrl = null), + content = aMessageEvent().content, + reactionsState = aTimelineItemReactions(count = 0), + readReceiptState = TimelineItemReadReceipts(emptyList().toImmutableList()), + localSendState = LocalEventSendState.Sent(AN_EVENT_ID), + isEditable = false, + canBeRepliedTo = false, + inReplyTo = null, + threadInfo = null, + origin = null, + timelineItemDebugInfoProvider = { aTimelineItemDebugInfo() }, + messageShieldProvider = { null }, + sendHandleProvider = { FakeSendHandle() }, + forwarder = null, + forwarderProfile = null, + ) + + private fun aDaySeparator(date: String) = TimelineItem.Virtual( + id = UniqueId("day_$date"), + model = aTimelineItemDaySeparatorModel(date) + ) + + @Test + fun `filterEmptyDaySeparators keeps day separator with events after it`() { + val items = listOf( + anEvent, + aDaySeparator("Today"), + ) + val result = filterEmptyDaySeparators(items) + assertThat(result).hasSize(2) + assertThat(result[0]).isEqualTo(anEvent) + assertThat(result[1]).isEqualTo(aDaySeparator("Today")) + } + + @Test + fun `filterEmptyDaySeparators removes day separator with no events after it`() { + val items = listOf( + aDaySeparator("Today"), + aDaySeparator("Yesterday"), + ) + val result = filterEmptyDaySeparators(items) + assertThat(result).isEmpty() + } + + @Test + fun `filterEmptyDaySeparators removes first day separator and keeps second when only second has events`() { + val items = listOf( + aDaySeparator("Today"), + anEvent, + aDaySeparator("Yesterday"), + ) + val result = filterEmptyDaySeparators(items) + assertThat(result).hasSize(2) + assertThat(result[0]).isEqualTo(anEvent) + assertThat(result[1]).isEqualTo(aDaySeparator("Yesterday")) + } + + @Test + fun `filterEmptyDaySeparators handles multiple day separators in a row with no events`() { + val items = listOf( + aDaySeparator("Today"), + aDaySeparator("Yesterday"), + aDaySeparator("Last week"), + ) + val result = filterEmptyDaySeparators(items) + assertThat(result).isEmpty() + } + + @Test + fun `filterEmptyDaySeparators keeps all items when no day separators`() { + val items = listOf( + anEvent, + anEvent.copy(id = UniqueId("event2")), + ) + val result = filterEmptyDaySeparators(items) + assertThat(result).hasSize(2) + } + + @Test + fun `filterEmptyDaySeparators handles grouped events after day separator`() { + val groupedEvents = TimelineItem.GroupedEvents( + id = UniqueId("grouped"), + events = listOf(anEvent).toImmutableList(), + aggregatedReadReceipts = emptyList().toImmutableList(), + ) + val items = listOf( + groupedEvents, + aDaySeparator("Today"), + ) + val result = filterEmptyDaySeparators(items) + assertThat(result).hasSize(2) + assertThat(result[0]).isEqualTo(groupedEvents) + assertThat(result[1]).isEqualTo(aDaySeparator("Today")) + } + + @Test + fun `filterEmptyDaySeparators removes day separator followed by non-event virtual item`() { + val readMarker = TimelineItem.Virtual( + id = UniqueId("readMarker"), + model = TimelineItemReadMarkerModel + ) + val items = listOf( + aDaySeparator("Today"), + readMarker, + ) + val result = filterEmptyDaySeparators(items) + assertThat(result).hasSize(1) + assertThat(result[0]).isEqualTo(readMarker) + } + + @Test + fun `filterEmptyDaySeparators keeps day separator when non-event virtual items are between separator and event`() { + val readMarker = TimelineItem.Virtual( + id = UniqueId("readMarker"), + model = TimelineItemReadMarkerModel + ) + val items = listOf( + anEvent, + readMarker, + aDaySeparator("Today"), + ) + val result = filterEmptyDaySeparators(items) + assertThat(result).hasSize(3) + assertThat(result[2]).isEqualTo(aDaySeparator("Today")) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index 5da4be408d..016204e1b1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.androidutils.hash.hash import io.element.android.libraries.core.extensions.runCatchingExceptions 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.media.AudioInfo import io.element.android.libraries.matrix.api.media.FileInfo import io.element.android.libraries.matrix.api.media.ImageInfo @@ -20,6 +21,7 @@ import io.element.android.libraries.matrix.api.media.VideoInfo import io.element.android.libraries.matrix.api.poll.PollKind import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.location.AssetType import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.MsgType @@ -43,6 +45,7 @@ import io.element.android.libraries.matrix.impl.timeline.postprocessor.TypingNot import io.element.android.libraries.matrix.impl.timeline.reply.InReplyToMapper import io.element.android.libraries.matrix.impl.util.MessageEventContent import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -121,6 +124,13 @@ class RustTimeline( private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode) private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode) + private data class RoomTimelineInfo( + val roomCreators: ImmutableList, + val isDm: Boolean, + val joinRule: JoinRule?, + val isEncrypted: Boolean?, + ) + override val backwardPaginationStatus = MutableStateFlow( Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PinnedEvents) ) @@ -220,20 +230,23 @@ class RustTimeline( _timelineItems, backwardPaginationStatus, forwardPaginationStatus, - joinedRoom.roomInfoFlow.map { it.creators to it.isDm }.distinctUntilChanged(), + joinedRoom.roomInfoFlow.map { RoomTimelineInfo(it.creators, it.isDm, it.joinRule, it.isEncrypted) }.distinctUntilChanged(), ) { timelineItems, backwardPaginationStatus, forwardPaginationStatus, - (roomCreators, isDm), + roomInfo, -> withContext(dispatcher) { + val (roomCreators, isDm, joinRule, isEncrypted) = roomInfo timelineItems .let { items -> roomBeginningPostProcessor.process( items = items, isDm = isDm, roomCreator = roomCreators.firstOrNull(), + joinRule = joinRule, + isEncrypted = isEncrypted, hasMoreToLoadBackwards = backwardPaginationStatus.hasMoreToLoad, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt index 397280231d..ec6a7a5380 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessor.kt @@ -9,33 +9,49 @@ package io.element.android.libraries.matrix.impl.timeline.postprocessor import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.matrix.api.timeline.item.event.OtherState +import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent import io.element.android.libraries.matrix.api.timeline.item.event.StateContent /** * This timeline post-processor removes the room creation event and the self-join event from the timeline for DMs - * or add the RoomBeginning item. + * or add the RoomBeginning item. For rooms that aren't invite-only and aren't encrypted, it also removes join/leave and profile change events. */ class RoomBeginningPostProcessor(private val mode: Timeline.Mode) { fun process( items: List, isDm: Boolean, roomCreator: UserId?, + joinRule: JoinRule?, + isEncrypted: Boolean?, hasMoreToLoadBackwards: Boolean, ): List { return when { items.isEmpty() -> items mode == Timeline.Mode.PinnedEvents -> items + joinRule !is JoinRule.Invite && isEncrypted == false -> filterRoomMemberEvents(items) isDm -> processForDM(items, roomCreator) hasMoreToLoadBackwards -> items else -> processForRoom(items) } } + private fun filterRoomMemberEvents(items: List): List { + return items.filter { item -> + val eventContent = (item as? MatrixTimelineItem.Event)?.event?.content + when (eventContent) { + is RoomMembershipContent -> eventContent.change !in listOf(MembershipChange.JOINED, MembershipChange.LEFT) + is ProfileChangeContent -> false + else -> true + } + } + } + private fun processForRoom(items: List): List { // No changes needed, timeline start item is already added by the SDK return items diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt index 50f8637096..cda5a07b8e 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/Fixtures.kt @@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTime import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.timeline.aMessageContent +import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent @@ -36,6 +37,14 @@ internal val otherMemberJoinEvent = MatrixTimelineItem.Event( uniqueId = UniqueId("m.room.member_other"), event = anEventTimelineItem(content = aRoomMembershipContent(userId = A_USER_ID_2, change = MembershipChange.JOINED)) ) +internal val otherMemberLeaveEvent = MatrixTimelineItem.Event( + uniqueId = UniqueId("m.room.member_leave"), + event = anEventTimelineItem(content = aRoomMembershipContent(userId = A_USER_ID_2, change = MembershipChange.LEFT)) +) +internal val profileChangeEvent = MatrixTimelineItem.Event( + uniqueId = UniqueId("m.room.member_profile"), + event = anEventTimelineItem(content = aProfileChangeMessageContent(displayName = "New Name", prevDisplayName = "Old Name")) +) internal val messageEvent = MatrixTimelineItem.Event( uniqueId = UniqueId("m.room.message"), event = anEventTimelineItem(content = aMessageContent("hi")) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt index dbeba39973..b562847d94 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.impl.timeline.postprocessor import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.test.A_USER_ID import org.junit.Test @@ -21,6 +22,8 @@ class RoomBeginningPostProcessorTest { items = emptyList(), isDm = true, roomCreator = A_USER_ID, + joinRule = null, + isEncrypted = null, hasMoreToLoadBackwards = false, ) assertThat(processedItems).isEmpty() @@ -33,6 +36,8 @@ class RoomBeginningPostProcessorTest { items = listOf(messageEvent), isDm = true, roomCreator = A_USER_ID, + joinRule = null, + isEncrypted = null, hasMoreToLoadBackwards = false, ) assertThat(processedItems).isEqualTo(listOf(messageEvent)) @@ -45,6 +50,8 @@ class RoomBeginningPostProcessorTest { items = listOf(messageEvent), isDm = true, roomCreator = null, + joinRule = null, + isEncrypted = null, hasMoreToLoadBackwards = false, ) assertThat(processedItems).isEqualTo(listOf(messageEvent)) @@ -62,6 +69,8 @@ class RoomBeginningPostProcessorTest { items = timelineItems, isDm = true, roomCreator = A_USER_ID, + joinRule = null, + isEncrypted = null, hasMoreToLoadBackwards = false, ) assertThat(processedItems).containsExactly(timelineStartEvent) @@ -78,6 +87,8 @@ class RoomBeginningPostProcessorTest { items = timelineItems, isDm = true, roomCreator = A_USER_ID, + joinRule = null, + isEncrypted = null, hasMoreToLoadBackwards = false, ) assertThat(processedItems).isEqualTo(timelineItems) @@ -96,7 +107,14 @@ class RoomBeginningPostProcessorTest { messageEvent, ) val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) - val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = false) + val processedItems = processor.process( + timelineItems, + isDm = true, + roomCreator = A_USER_ID, + joinRule = null, + isEncrypted = null, + hasMoreToLoadBackwards = false + ) assertThat(processedItems).isEqualTo(expected) } @@ -107,7 +125,14 @@ class RoomBeginningPostProcessorTest { roomCreatorJoinEvent, ) val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) - val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true) + val processedItems = processor.process( + timelineItems, + isDm = true, + roomCreator = A_USER_ID, + joinRule = null, + isEncrypted = null, + hasMoreToLoadBackwards = true + ) assertThat(processedItems).isEmpty() } @@ -117,7 +142,14 @@ class RoomBeginningPostProcessorTest { roomCreatorJoinEvent, ) val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) - val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true) + val processedItems = processor.process( + timelineItems, + isDm = true, + roomCreator = A_USER_ID, + joinRule = null, + isEncrypted = null, + hasMoreToLoadBackwards = true + ) assertThat(processedItems).isEmpty() } @@ -128,7 +160,84 @@ class RoomBeginningPostProcessorTest { otherMemberJoinEvent, ) val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) - val processedItems = processor.process(timelineItems, isDm = true, roomCreator = A_USER_ID, hasMoreToLoadBackwards = true) + val processedItems = processor.process( + timelineItems, + isDm = true, + roomCreator = A_USER_ID, + joinRule = null, + isEncrypted = null, + hasMoreToLoadBackwards = true + ) assertThat(processedItems).isEqualTo(listOf(otherMemberJoinEvent)) } + + @Test + fun `processor removes join, leave, and profile events in unencrypted public rooms`() { + val timelineItems = listOf( + roomCreateEvent, + roomCreatorJoinEvent, + otherMemberJoinEvent, + messageEvent, + otherMemberLeaveEvent, + profileChangeEvent, + ) + val expected = listOf( + roomCreateEvent, + messageEvent, + ) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) + val processedItems = processor.process( + timelineItems, + isDm = false, + roomCreator = A_USER_ID, + joinRule = JoinRule.Public, + isEncrypted = false, + hasMoreToLoadBackwards = false + ) + assertThat(processedItems).isEqualTo(expected) + } + + @Test + fun `processor keeps all events in encrypted public rooms`() { + val timelineItems = listOf( + roomCreateEvent, + roomCreatorJoinEvent, + otherMemberJoinEvent, + messageEvent, + otherMemberLeaveEvent, + profileChangeEvent, + ) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) + val processedItems = processor.process( + timelineItems, + isDm = false, + roomCreator = A_USER_ID, + joinRule = JoinRule.Public, + isEncrypted = true, + hasMoreToLoadBackwards = false + ) + assertThat(processedItems).isEqualTo(timelineItems) + } + + @Test + fun `processor keeps membership events in invite-only rooms`() { + val timelineItems = listOf( + roomCreateEvent, + roomCreatorJoinEvent, + otherMemberJoinEvent, + messageEvent, + otherMemberLeaveEvent, + profileChangeEvent, + ) + val processor = RoomBeginningPostProcessor(Timeline.Mode.Live) + val processedItems = processor.process( + timelineItems, + isDm = false, + roomCreator = A_USER_ID, + joinRule = JoinRule.Invite, + isEncrypted = null, + hasMoreToLoadBackwards = false + ) + assertThat(processedItems).isEqualTo(timelineItems) + } }