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 @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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<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()
}
Original file line number Diff line number Diff line change
@@ -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<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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -121,6 +124,13 @@ class RustTimeline(
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode)
private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode)

private data class RoomTimelineInfo(
val roomCreators: ImmutableList<UserId>,
val isDm: Boolean,
val joinRule: JoinRule?,
val isEncrypted: Boolean?,
)

override val backwardPaginationStatus = MutableStateFlow(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode != Timeline.Mode.PinnedEvents)
)
Expand Down Expand Up @@ -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,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MatrixTimelineItem>,
isDm: Boolean,
roomCreator: UserId?,
joinRule: JoinRule?,
isEncrypted: Boolean?,
hasMoreToLoadBackwards: Boolean,
): List<MatrixTimelineItem> {
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<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> {
// No changes needed, timeline start item is already added by the SDK
return items
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"))
Expand Down
Loading
Loading