Move empty day separator filtering to a timeline post-processor (#6866)
* Move empty day separator filtering to a timeline post-processor * Split `FilterPublicMembershipChangesPostProcessor` from `RoomBeginningPostProcessor`
This commit is contained in:
parent
1f927b9965
commit
6cd0491292
9 changed files with 292 additions and 293 deletions
|
|
@ -16,7 +16,6 @@ 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.factories.virtual.TimelineItemVirtualFactory
|
||||||
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
|
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.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.DiffCacheUpdater
|
||||||
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
|
import io.element.android.libraries.androidutils.diff.MutableListDiffCache
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
|
|
@ -97,8 +96,7 @@ class TimelineItemsFactory(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val result = timelineItemGrouper.group(newTimelineItemStates).toImmutableList()
|
val result = timelineItemGrouper.group(newTimelineItemStates).toImmutableList()
|
||||||
val filteredResult = filterEmptyDaySeparators(result)
|
this._timelineItems.emit(result)
|
||||||
this._timelineItems.emit(filteredResult)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildAndCacheItem(
|
private suspend fun buildAndCacheItem(
|
||||||
|
|
@ -116,25 +114,3 @@ class TimelineItemsFactory(
|
||||||
return timelineItem
|
return timelineItem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove day separators for days with no events after the client-side event filtering
|
|
||||||
internal fun filterEmptyDaySeparators(items: List<TimelineItem>): ImmutableList<TimelineItem> {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
/*
|
|
||||||
* 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<ReadReceiptData>().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<ReadReceiptData>().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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -38,6 +38,8 @@ import io.element.android.libraries.matrix.impl.room.location.into
|
||||||
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
|
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
|
||||||
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||||
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
|
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
|
||||||
|
import io.element.android.libraries.matrix.impl.timeline.postprocessor.FilterEmptyDayPostProcessor
|
||||||
|
import io.element.android.libraries.matrix.impl.timeline.postprocessor.FilterPublicMembershipChangesPostProcessor
|
||||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LastForwardIndicatorsPostProcessor
|
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LastForwardIndicatorsPostProcessor
|
||||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor
|
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor
|
||||||
import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor
|
import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor
|
||||||
|
|
@ -84,7 +86,7 @@ private const val PAGINATION_SIZE = 50
|
||||||
class RustTimeline(
|
class RustTimeline(
|
||||||
private val inner: InnerTimeline,
|
private val inner: InnerTimeline,
|
||||||
override val mode: Timeline.Mode,
|
override val mode: Timeline.Mode,
|
||||||
private val systemClock: SystemClock,
|
systemClock: SystemClock,
|
||||||
private val joinedRoom: JoinedRoom,
|
private val joinedRoom: JoinedRoom,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val dispatcher: CoroutineDispatcher,
|
private val dispatcher: CoroutineDispatcher,
|
||||||
|
|
@ -123,6 +125,8 @@ class RustTimeline(
|
||||||
private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock)
|
private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock)
|
||||||
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode)
|
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode)
|
||||||
private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode)
|
private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode)
|
||||||
|
private val publicMembershipChangesPostProcessor = FilterPublicMembershipChangesPostProcessor()
|
||||||
|
private val emptyDayPostProcessor = FilterEmptyDayPostProcessor()
|
||||||
|
|
||||||
private data class RoomTimelineInfo(
|
private data class RoomTimelineInfo(
|
||||||
val roomCreators: ImmutableList<UserId>,
|
val roomCreators: ImmutableList<UserId>,
|
||||||
|
|
@ -245,11 +249,21 @@ class RustTimeline(
|
||||||
items = items,
|
items = items,
|
||||||
isDm = isDm,
|
isDm = isDm,
|
||||||
roomCreator = roomCreators.firstOrNull(),
|
roomCreator = roomCreators.firstOrNull(),
|
||||||
joinRule = joinRule,
|
|
||||||
isEncrypted = isEncrypted,
|
|
||||||
hasMoreToLoadBackwards = backwardPaginationStatus.hasMoreToLoad,
|
hasMoreToLoadBackwards = backwardPaginationStatus.hasMoreToLoad,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// This should be the first post processor after room beginning.
|
||||||
|
.let { items ->
|
||||||
|
publicMembershipChangesPostProcessor.process(
|
||||||
|
items = items,
|
||||||
|
joinRule = joinRule,
|
||||||
|
isEncrypted = isEncrypted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// After removing public membership changes, we might end up with empty days, so we need to filter them out.
|
||||||
|
.let { items ->
|
||||||
|
emptyDayPostProcessor.process(items)
|
||||||
|
}
|
||||||
.let { items ->
|
.let { items ->
|
||||||
loadingIndicatorsPostProcessor.process(
|
loadingIndicatorsPostProcessor.process(
|
||||||
items = items,
|
items = items,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.impl.timeline.postprocessor
|
||||||
|
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-processor to filter out day separators for days that don't contain any events.
|
||||||
|
*/
|
||||||
|
class FilterEmptyDayPostProcessor {
|
||||||
|
/**
|
||||||
|
* Filters out day separators from [items] for days that don't contain any events.
|
||||||
|
*/
|
||||||
|
fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> = buildList {
|
||||||
|
// The timeline is ordered by descending timestamp, so events that happened during a day appear before the day separator for that day.
|
||||||
|
// We can use this to determine if a day separator should be kept or not.
|
||||||
|
var hasEvent = false
|
||||||
|
for (item in items) {
|
||||||
|
if (item is MatrixTimelineItem.Event) {
|
||||||
|
hasEvent = true
|
||||||
|
add(item)
|
||||||
|
} else if (item is MatrixTimelineItem.Virtual && item.virtual is VirtualTimelineItem.DayDivider) {
|
||||||
|
if (hasEvent) {
|
||||||
|
add(item)
|
||||||
|
hasEvent = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
add(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.impl.timeline.postprocessor
|
||||||
|
|
||||||
|
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.item.event.MembershipChange
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-processor to filter out public membership changes for non-encrypted, publicly joinable rooms.
|
||||||
|
*/
|
||||||
|
class FilterPublicMembershipChangesPostProcessor {
|
||||||
|
/**
|
||||||
|
* Filters out public membership changes from [items] if the room is publicly joinable and not encrypted.
|
||||||
|
*/
|
||||||
|
fun process(
|
||||||
|
items: List<MatrixTimelineItem>,
|
||||||
|
joinRule: JoinRule?,
|
||||||
|
isEncrypted: Boolean?,
|
||||||
|
): List<MatrixTimelineItem> {
|
||||||
|
return if (joinRule !is JoinRule.Invite && isEncrypted == false) {
|
||||||
|
filterMembershipEvents(items)
|
||||||
|
} else {
|
||||||
|
items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterMembershipEvents(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> = items.filter { item ->
|
||||||
|
val eventContent = (item as? MatrixTimelineItem.Event)?.event?.content ?: return@filter true
|
||||||
|
when (eventContent) {
|
||||||
|
is RoomMembershipContent -> eventContent.change != null && eventContent.change !in listOf(MembershipChange.JOINED, MembershipChange.LEFT)
|
||||||
|
is ProfileChangeContent -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,12 +9,10 @@
|
||||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
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.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.MatrixTimelineItem
|
||||||
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.MembershipChange
|
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.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.RoomMembershipContent
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
|
||||||
|
|
||||||
|
|
@ -27,31 +25,17 @@ class RoomBeginningPostProcessor(private val mode: Timeline.Mode) {
|
||||||
items: List<MatrixTimelineItem>,
|
items: List<MatrixTimelineItem>,
|
||||||
isDm: Boolean,
|
isDm: Boolean,
|
||||||
roomCreator: UserId?,
|
roomCreator: UserId?,
|
||||||
joinRule: JoinRule?,
|
|
||||||
isEncrypted: Boolean?,
|
|
||||||
hasMoreToLoadBackwards: Boolean,
|
hasMoreToLoadBackwards: Boolean,
|
||||||
): List<MatrixTimelineItem> {
|
): List<MatrixTimelineItem> {
|
||||||
return when {
|
return when {
|
||||||
items.isEmpty() -> items
|
items.isEmpty() -> items
|
||||||
mode == Timeline.Mode.PinnedEvents -> items
|
mode == Timeline.Mode.PinnedEvents -> items
|
||||||
joinRule !is JoinRule.Invite && isEncrypted == false -> filterRoomMemberEvents(items)
|
|
||||||
isDm -> processForDM(items, roomCreator)
|
isDm -> processForDM(items, roomCreator)
|
||||||
hasMoreToLoadBackwards -> items
|
hasMoreToLoadBackwards -> items
|
||||||
else -> processForRoom(items)
|
else -> processForRoom(items)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun filterRoomMemberEvents(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
|
||||||
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<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
private fun processForRoom(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> {
|
||||||
// No changes needed, timeline start item is already added by the SDK
|
// No changes needed, timeline start item is already added by the SDK
|
||||||
return items
|
return items
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.impl.timeline.postprocessor
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||||
|
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
|
||||||
|
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
private const val TODAY = 1_779_779_967_000
|
||||||
|
private const val YESTERDAY = TODAY - 24 * 60 * 60 * 1000
|
||||||
|
private const val DAY_BEFORE_YESTERDAY = YESTERDAY - 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
class FilterEmptyDayPostProcessorTest {
|
||||||
|
private val anEvent = MatrixTimelineItem.Event(
|
||||||
|
uniqueId = UniqueId("event"),
|
||||||
|
event = anEventTimelineItem(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun aDaySeparator(timestmap: Long) = MatrixTimelineItem.Virtual(
|
||||||
|
uniqueId = UniqueId("day_$timestmap"),
|
||||||
|
virtual = VirtualTimelineItem.DayDivider(timestmap)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `filterEmptyDaySeparators keeps day separator with events after it`() {
|
||||||
|
val items = listOf(
|
||||||
|
anEvent,
|
||||||
|
aDaySeparator(TODAY),
|
||||||
|
)
|
||||||
|
val result = FilterEmptyDayPostProcessor().process(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 = FilterEmptyDayPostProcessor().process(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 = FilterEmptyDayPostProcessor().process(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(DAY_BEFORE_YESTERDAY),
|
||||||
|
)
|
||||||
|
val result = FilterEmptyDayPostProcessor().process(items)
|
||||||
|
assertThat(result).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `filterEmptyDaySeparators keeps all items when no day separators`() {
|
||||||
|
val items = listOf(
|
||||||
|
anEvent,
|
||||||
|
anEvent.copy(uniqueId = UniqueId("event2")),
|
||||||
|
)
|
||||||
|
val result = FilterEmptyDayPostProcessor().process(items)
|
||||||
|
assertThat(result).hasSize(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `filterEmptyDaySeparators removes day separator followed by non-event virtual item`() {
|
||||||
|
val readMarker = MatrixTimelineItem.Virtual(
|
||||||
|
uniqueId = UniqueId("readMarker"),
|
||||||
|
virtual = VirtualTimelineItem.ReadMarker
|
||||||
|
)
|
||||||
|
val items = listOf(
|
||||||
|
aDaySeparator(TODAY),
|
||||||
|
readMarker,
|
||||||
|
)
|
||||||
|
val result = FilterEmptyDayPostProcessor().process(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 = MatrixTimelineItem.Virtual(
|
||||||
|
uniqueId = UniqueId("readMarker"),
|
||||||
|
virtual = VirtualTimelineItem.ReadMarker
|
||||||
|
)
|
||||||
|
val items = listOf(
|
||||||
|
anEvent,
|
||||||
|
readMarker,
|
||||||
|
aDaySeparator(TODAY),
|
||||||
|
)
|
||||||
|
val result = FilterEmptyDayPostProcessor().process(items)
|
||||||
|
assertThat(result).hasSize(3)
|
||||||
|
assertThat(result[2]).isEqualTo(aDaySeparator(TODAY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
/*
|
||||||
|
* 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.libraries.matrix.impl.timeline.postprocessor
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import io.element.android.libraries.matrix.api.room.join.JoinRule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FilterPublicMembershipChangesPostProcessorTest {
|
||||||
|
@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 = FilterPublicMembershipChangesPostProcessor()
|
||||||
|
val processedItems = processor.process(
|
||||||
|
timelineItems,
|
||||||
|
joinRule = JoinRule.Public,
|
||||||
|
isEncrypted = 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 = FilterPublicMembershipChangesPostProcessor()
|
||||||
|
val processedItems = processor.process(
|
||||||
|
timelineItems,
|
||||||
|
joinRule = JoinRule.Public,
|
||||||
|
isEncrypted = true,
|
||||||
|
)
|
||||||
|
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 = FilterPublicMembershipChangesPostProcessor()
|
||||||
|
val processedItems = processor.process(
|
||||||
|
timelineItems,
|
||||||
|
joinRule = JoinRule.Invite,
|
||||||
|
isEncrypted = null,
|
||||||
|
)
|
||||||
|
assertThat(processedItems).isEqualTo(timelineItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,6 @@
|
||||||
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
package io.element.android.libraries.matrix.impl.timeline.postprocessor
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -22,8 +21,6 @@ class RoomBeginningPostProcessorTest {
|
||||||
items = emptyList(),
|
items = emptyList(),
|
||||||
isDm = true,
|
isDm = true,
|
||||||
roomCreator = A_USER_ID,
|
roomCreator = A_USER_ID,
|
||||||
joinRule = null,
|
|
||||||
isEncrypted = null,
|
|
||||||
hasMoreToLoadBackwards = false,
|
hasMoreToLoadBackwards = false,
|
||||||
)
|
)
|
||||||
assertThat(processedItems).isEmpty()
|
assertThat(processedItems).isEmpty()
|
||||||
|
|
@ -36,8 +33,6 @@ class RoomBeginningPostProcessorTest {
|
||||||
items = listOf(messageEvent),
|
items = listOf(messageEvent),
|
||||||
isDm = true,
|
isDm = true,
|
||||||
roomCreator = A_USER_ID,
|
roomCreator = A_USER_ID,
|
||||||
joinRule = null,
|
|
||||||
isEncrypted = null,
|
|
||||||
hasMoreToLoadBackwards = false,
|
hasMoreToLoadBackwards = false,
|
||||||
)
|
)
|
||||||
assertThat(processedItems).isEqualTo(listOf(messageEvent))
|
assertThat(processedItems).isEqualTo(listOf(messageEvent))
|
||||||
|
|
@ -50,8 +45,6 @@ class RoomBeginningPostProcessorTest {
|
||||||
items = listOf(messageEvent),
|
items = listOf(messageEvent),
|
||||||
isDm = true,
|
isDm = true,
|
||||||
roomCreator = null,
|
roomCreator = null,
|
||||||
joinRule = null,
|
|
||||||
isEncrypted = null,
|
|
||||||
hasMoreToLoadBackwards = false,
|
hasMoreToLoadBackwards = false,
|
||||||
)
|
)
|
||||||
assertThat(processedItems).isEqualTo(listOf(messageEvent))
|
assertThat(processedItems).isEqualTo(listOf(messageEvent))
|
||||||
|
|
@ -69,8 +62,6 @@ class RoomBeginningPostProcessorTest {
|
||||||
items = timelineItems,
|
items = timelineItems,
|
||||||
isDm = true,
|
isDm = true,
|
||||||
roomCreator = A_USER_ID,
|
roomCreator = A_USER_ID,
|
||||||
joinRule = null,
|
|
||||||
isEncrypted = null,
|
|
||||||
hasMoreToLoadBackwards = false,
|
hasMoreToLoadBackwards = false,
|
||||||
)
|
)
|
||||||
assertThat(processedItems).containsExactly(timelineStartEvent)
|
assertThat(processedItems).containsExactly(timelineStartEvent)
|
||||||
|
|
@ -87,8 +78,6 @@ class RoomBeginningPostProcessorTest {
|
||||||
items = timelineItems,
|
items = timelineItems,
|
||||||
isDm = true,
|
isDm = true,
|
||||||
roomCreator = A_USER_ID,
|
roomCreator = A_USER_ID,
|
||||||
joinRule = null,
|
|
||||||
isEncrypted = null,
|
|
||||||
hasMoreToLoadBackwards = false,
|
hasMoreToLoadBackwards = false,
|
||||||
)
|
)
|
||||||
assertThat(processedItems).isEqualTo(timelineItems)
|
assertThat(processedItems).isEqualTo(timelineItems)
|
||||||
|
|
@ -111,8 +100,6 @@ class RoomBeginningPostProcessorTest {
|
||||||
timelineItems,
|
timelineItems,
|
||||||
isDm = true,
|
isDm = true,
|
||||||
roomCreator = A_USER_ID,
|
roomCreator = A_USER_ID,
|
||||||
joinRule = null,
|
|
||||||
isEncrypted = null,
|
|
||||||
hasMoreToLoadBackwards = false
|
hasMoreToLoadBackwards = false
|
||||||
)
|
)
|
||||||
assertThat(processedItems).isEqualTo(expected)
|
assertThat(processedItems).isEqualTo(expected)
|
||||||
|
|
@ -129,8 +116,6 @@ class RoomBeginningPostProcessorTest {
|
||||||
timelineItems,
|
timelineItems,
|
||||||
isDm = true,
|
isDm = true,
|
||||||
roomCreator = A_USER_ID,
|
roomCreator = A_USER_ID,
|
||||||
joinRule = null,
|
|
||||||
isEncrypted = null,
|
|
||||||
hasMoreToLoadBackwards = true
|
hasMoreToLoadBackwards = true
|
||||||
)
|
)
|
||||||
assertThat(processedItems).isEmpty()
|
assertThat(processedItems).isEmpty()
|
||||||
|
|
@ -146,8 +131,6 @@ class RoomBeginningPostProcessorTest {
|
||||||
timelineItems,
|
timelineItems,
|
||||||
isDm = true,
|
isDm = true,
|
||||||
roomCreator = A_USER_ID,
|
roomCreator = A_USER_ID,
|
||||||
joinRule = null,
|
|
||||||
isEncrypted = null,
|
|
||||||
hasMoreToLoadBackwards = true
|
hasMoreToLoadBackwards = true
|
||||||
)
|
)
|
||||||
assertThat(processedItems).isEmpty()
|
assertThat(processedItems).isEmpty()
|
||||||
|
|
@ -164,80 +147,8 @@ class RoomBeginningPostProcessorTest {
|
||||||
timelineItems,
|
timelineItems,
|
||||||
isDm = true,
|
isDm = true,
|
||||||
roomCreator = A_USER_ID,
|
roomCreator = A_USER_ID,
|
||||||
joinRule = null,
|
|
||||||
isEncrypted = null,
|
|
||||||
hasMoreToLoadBackwards = true
|
hasMoreToLoadBackwards = true
|
||||||
)
|
)
|
||||||
assertThat(processedItems).isEqualTo(listOf(otherMemberJoinEvent))
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue