Do not show membership/profile events in public rooms (#6360)

* 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 <jorgem@element.io>
This commit is contained in:
bxdxnn 2026-05-25 11:31:53 +03:00 committed by GitHub
parent 5277382e6d
commit b31dad4b26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 339 additions and 8 deletions

View file

@ -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<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()
}

View file

@ -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"))
}
}

View file

@ -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<UserId>,
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,
)
}

View file

@ -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

View file

@ -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"))

View file

@ -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)
}
}