Skip to content

Commit b08337e

Browse files
authored
Merge pull request #6501 from vector-im/feature/mna/collapse-deleted-events
[Timeline] - Collapse redacted events (PSG-523)
2 parents 9976b3b + 9c61900 commit b08337e

File tree

6 files changed

+179
-42
lines changed

6 files changed

+179
-42
lines changed

changelog.d/6487.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[Timeline] - Collapse redacted events

matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ data class Event(
202202
* It will return a decrypted text message or an empty string otherwise.
203203
*/
204204
fun getDecryptedTextSummary(): String? {
205-
if (isRedacted()) return "Message Deleted"
205+
if (isRedacted()) return "Message removed"
206206
val text = getDecryptedValue() ?: run {
207207
if (isPoll()) {
208208
return getPollQuestion() ?: "created a poll."

vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt

Lines changed: 107 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import im.vector.app.features.home.room.detail.timeline.TimelineEventController
2424
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
2525
import im.vector.app.features.home.room.detail.timeline.helper.MergedTimelineEventVisibilityStateChangedListener
2626
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper
27-
import im.vector.app.features.home.room.detail.timeline.helper.canBeMerged
2827
import im.vector.app.features.home.room.detail.timeline.helper.isRoomConfiguration
2928
import im.vector.app.features.home.room.detail.timeline.item.BasedMergedItem
3029
import im.vector.app.features.home.room.detail.timeline.item.MergedRoomCreationItem
@@ -35,6 +34,7 @@ import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovement
3534
import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM
3635
import org.matrix.android.sdk.api.extensions.orFalse
3736
import org.matrix.android.sdk.api.query.QueryStringValue
37+
import org.matrix.android.sdk.api.session.events.model.Event
3838
import org.matrix.android.sdk.api.session.events.model.EventType
3939
import org.matrix.android.sdk.api.session.events.model.content.EncryptionEventContent
4040
import org.matrix.android.sdk.api.session.events.model.toModel
@@ -53,6 +53,7 @@ class MergedHeaderItemFactory @Inject constructor(
5353
private val timelineEventVisibilityHelper: TimelineEventVisibilityHelper
5454
) {
5555

56+
private val mergeableEventTypes = listOf(EventType.STATE_ROOM_MEMBER, EventType.STATE_ROOM_SERVER_ACL)
5657
private val collapsedEventIds = linkedSetOf<Long>()
5758
private val mergeItemCollapseStates = HashMap<Long, Boolean>()
5859

@@ -78,19 +79,65 @@ class MergedHeaderItemFactory @Inject constructor(
7879
callback: TimelineEventController.Callback?,
7980
requestModelBuild: () -> Unit
8081
): BasedMergedItem<*>? {
81-
return if (nextEvent?.root?.getClearType() == EventType.STATE_ROOM_CREATE &&
82-
event.isRoomConfiguration(nextEvent.root.getClearContent()?.toModel<RoomCreateContent>()?.creator)) {
83-
// It's the first item before room.create
84-
// Collapse all room configuration events
85-
buildRoomCreationMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
86-
} else if (!event.canBeMerged() || (nextEvent?.root?.getClearType() == event.root.getClearType() && !addDaySeparator)) {
87-
null
88-
} else {
89-
buildMembershipEventsMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
82+
return when {
83+
isStartOfRoomCreationSummary(event, nextEvent) ->
84+
buildRoomCreationMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
85+
isStartOfSameTypeEventsSummary(event, nextEvent, addDaySeparator) ->
86+
buildSameTypeEventsMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
87+
isStartOfRedactedEventsSummary(event, items, currentPosition, addDaySeparator) ->
88+
buildRedactedEventsMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
89+
else -> null
9090
}
9191
}
9292

93-
private fun buildMembershipEventsMergedSummary(
93+
/**
94+
* @param event the main timeline event
95+
* @param nextEvent is an older event than event
96+
*/
97+
private fun isStartOfRoomCreationSummary(
98+
event: TimelineEvent,
99+
nextEvent: TimelineEvent?,
100+
): Boolean {
101+
// It's the first item before room.create
102+
// Collapse all room configuration events
103+
return nextEvent?.root?.getClearType() == EventType.STATE_ROOM_CREATE &&
104+
event.isRoomConfiguration(nextEvent.root.getClearContent()?.toModel<RoomCreateContent>()?.creator)
105+
}
106+
107+
/**
108+
* @param event the main timeline event
109+
* @param nextEvent is an older event than event
110+
* @param addDaySeparator true to add a day separator
111+
*/
112+
private fun isStartOfSameTypeEventsSummary(
113+
event: TimelineEvent,
114+
nextEvent: TimelineEvent?,
115+
addDaySeparator: Boolean,
116+
): Boolean {
117+
return event.root.getClearType() in mergeableEventTypes &&
118+
(nextEvent?.root?.getClearType() != event.root.getClearType() || addDaySeparator)
119+
}
120+
121+
/**
122+
* @param event the main timeline event
123+
* @param items all known items, sorted from newer event to oldest event
124+
* @param currentPosition the current position
125+
* @param addDaySeparator true to add a day separator
126+
*/
127+
private fun isStartOfRedactedEventsSummary(
128+
event: TimelineEvent,
129+
items: List<TimelineEvent>,
130+
currentPosition: Int,
131+
addDaySeparator: Boolean,
132+
): Boolean {
133+
val nextNonRedactionEvent = items
134+
.subList(fromIndex = currentPosition + 1, toIndex = items.size)
135+
.find { it.root.getClearType() != EventType.REDACTION }
136+
return event.root.isRedacted() &&
137+
(!nextNonRedactionEvent?.root?.isRedacted().orFalse() || addDaySeparator)
138+
}
139+
140+
private fun buildSameTypeEventsMergedSummary(
94141
currentPosition: Int,
95142
items: List<TimelineEvent>,
96143
partialState: TimelineEventController.PartialState,
@@ -102,11 +149,42 @@ class MergedHeaderItemFactory @Inject constructor(
102149
val mergedEvents = timelineEventVisibilityHelper.prevSameTypeEvents(
103150
items,
104151
currentPosition,
105-
2,
152+
MIN_NUMBER_OF_MERGED_EVENTS,
106153
eventIdToHighlight,
107154
partialState.rootThreadEventId,
108155
partialState.isFromThreadTimeline()
109156
)
157+
return buildSimilarEventsMergedSummary(mergedEvents, partialState, event, eventIdToHighlight, requestModelBuild, callback)
158+
}
159+
160+
private fun buildRedactedEventsMergedSummary(
161+
currentPosition: Int,
162+
items: List<TimelineEvent>,
163+
partialState: TimelineEventController.PartialState,
164+
event: TimelineEvent,
165+
eventIdToHighlight: String?,
166+
requestModelBuild: () -> Unit,
167+
callback: TimelineEventController.Callback?
168+
): MergedSimilarEventsItem_? {
169+
val mergedEvents = timelineEventVisibilityHelper.prevRedactedEvents(
170+
items,
171+
currentPosition,
172+
MIN_NUMBER_OF_MERGED_EVENTS,
173+
eventIdToHighlight,
174+
partialState.rootThreadEventId,
175+
partialState.isFromThreadTimeline()
176+
)
177+
return buildSimilarEventsMergedSummary(mergedEvents, partialState, event, eventIdToHighlight, requestModelBuild, callback)
178+
}
179+
180+
private fun buildSimilarEventsMergedSummary(
181+
mergedEvents: List<TimelineEvent>,
182+
partialState: TimelineEventController.PartialState,
183+
event: TimelineEvent,
184+
eventIdToHighlight: String?,
185+
requestModelBuild: () -> Unit,
186+
callback: TimelineEventController.Callback?
187+
): MergedSimilarEventsItem_? {
110188
return if (mergedEvents.isEmpty()) {
111189
null
112190
} else {
@@ -127,7 +205,7 @@ class MergedHeaderItemFactory @Inject constructor(
127205
)
128206
mergedData.add(data)
129207
}
130-
val mergedEventIds = mergedEvents.map { it.localId }
208+
val mergedEventIds = mergedEvents.map { it.localId }.toSet()
131209
// We try to find if one of the item id were used as mergeItemCollapseStates key
132210
// => handle case where paginating from mergeable events and we get more
133211
val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull()
@@ -140,12 +218,7 @@ class MergedHeaderItemFactory @Inject constructor(
140218
collapsedEventIds.removeAll(mergedEventIds)
141219
}
142220
val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() }
143-
val summaryTitleResId = when (event.root.getClearType()) {
144-
EventType.STATE_ROOM_MEMBER -> R.plurals.membership_changes
145-
EventType.STATE_ROOM_SERVER_ACL -> R.plurals.notice_room_server_acl_changes
146-
else -> null
147-
}
148-
summaryTitleResId?.let { summaryTitle ->
221+
getSummaryTitleResId(event.root)?.let { summaryTitle ->
149222
val attributes = MergedSimilarEventsItem.Attributes(
150223
summaryTitleResId = summaryTitle,
151224
isCollapsed = isCollapsed,
@@ -168,6 +241,16 @@ class MergedHeaderItemFactory @Inject constructor(
168241
}
169242
}
170243

244+
private fun getSummaryTitleResId(event: Event): Int? {
245+
val type = event.getClearType()
246+
return when {
247+
type == EventType.STATE_ROOM_MEMBER -> R.plurals.membership_changes
248+
type == EventType.STATE_ROOM_SERVER_ACL -> R.plurals.notice_room_server_acl_changes
249+
event.isRedacted() -> R.plurals.room_removed_messages
250+
else -> null
251+
}
252+
}
253+
171254
private fun buildRoomCreationMergedSummary(
172255
currentPosition: Int,
173256
items: List<TimelineEvent>,
@@ -191,7 +274,7 @@ class MergedHeaderItemFactory @Inject constructor(
191274
tmpPos--
192275
prevEvent = items.getOrNull(tmpPos)
193276
}
194-
return if (mergedEvents.size > 2) {
277+
return if (mergedEvents.size > MIN_NUMBER_OF_MERGED_EVENTS) {
195278
var highlighted = false
196279
val mergedData = ArrayList<BasedMergedItem.Data>(mergedEvents.size)
197280
mergedEvents.reversed()
@@ -264,4 +347,8 @@ class MergedHeaderItemFactory @Inject constructor(
264347
fun isCollapsed(localId: Long): Boolean {
265348
return collapsedEventIds.contains(localId)
266349
}
350+
351+
companion object {
352+
private const val MIN_NUMBER_OF_MERGED_EVENTS = 2
353+
}
267354
}

vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,6 @@ object TimelineDisplayableEvents {
5454
) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
5555
}
5656

57-
fun TimelineEvent.canBeMerged(): Boolean {
58-
return root.getClearType() == EventType.STATE_ROOM_MEMBER ||
59-
root.getClearType() == EventType.STATE_ROOM_SERVER_ACL
60-
}
61-
6257
fun TimelineEvent.isRoomConfiguration(roomCreatorUserId: String?): Boolean {
6358
return root.isStateEvent() && when (root.getClearType()) {
6459
EventType.STATE_ROOM_GUEST_ACCESS,

vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt

Lines changed: 65 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.helper
1818

1919
import im.vector.app.core.extensions.localDateTime
2020
import im.vector.app.core.resources.UserPreferencesProvider
21+
import org.matrix.android.sdk.api.session.events.model.Event
2122
import org.matrix.android.sdk.api.session.events.model.EventType
2223
import org.matrix.android.sdk.api.session.events.model.RelationType
2324
import org.matrix.android.sdk.api.session.events.model.getRelationContent
@@ -30,25 +31,38 @@ import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho
3031
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
3132
import javax.inject.Inject
3233

33-
class TimelineEventVisibilityHelper @Inject constructor(private val userPreferencesProvider: UserPreferencesProvider) {
34+
class TimelineEventVisibilityHelper @Inject constructor(
35+
private val userPreferencesProvider: UserPreferencesProvider,
36+
) {
37+
38+
private interface PredicateToStopSearch {
39+
/**
40+
* Indicate whether a search on events should stop by comparing 2 given successive events.
41+
* @param oldEvent the oldest event between the 2 events to compare
42+
* @param newEvent the more recent event between the 2 events to compare
43+
*/
44+
fun shouldStopSearch(oldEvent: Event, newEvent: Event): Boolean
45+
}
3446

3547
/**
36-
* @param timelineEvents the events to search in
48+
* @param timelineEvents the events to search in, sorted from oldest event to newer event
3749
* @param index the index to start computing (inclusive)
3850
* @param minSize the minimum number of same type events to have sequentially, otherwise will return an empty list
3951
* @param eventIdToHighlight used to compute visibility
4052
* @param rootThreadEventId the root thread event id if in a thread timeline
4153
* @param isFromThreadTimeline true if the timeline is a thread
54+
* @param predicateToStop events are taken until this condition is met
4255
*
43-
* @return a list of timeline events which have sequentially the same type following the next direction.
56+
* @return a list of timeline events which meet sequentially the same criteria following the next direction.
4457
*/
45-
private fun nextSameTypeEvents(
58+
private fun nextEventsUntil(
4659
timelineEvents: List<TimelineEvent>,
4760
index: Int,
4861
minSize: Int,
4962
eventIdToHighlight: String?,
5063
rootThreadEventId: String?,
51-
isFromThreadTimeline: Boolean
64+
isFromThreadTimeline: Boolean,
65+
predicateToStop: PredicateToStopSearch
5266
): List<TimelineEvent> {
5367
if (index >= timelineEvents.size - 1) {
5468
return emptyList()
@@ -65,28 +79,27 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
6579
} else {
6680
nextSubList.subList(0, indexOfNextDay)
6781
}
68-
val indexOfFirstDifferentEventType = nextSameDayEvents.indexOfFirst { it.root.getClearType() != timelineEvent.root.getClearType() }
69-
val sameTypeEvents = if (indexOfFirstDifferentEventType == -1) {
82+
val indexOfFirstDifferentEvent = nextSameDayEvents.indexOfFirst {
83+
predicateToStop.shouldStopSearch(oldEvent = timelineEvent.root, newEvent = it.root)
84+
}
85+
val similarEvents = if (indexOfFirstDifferentEvent == -1) {
7086
nextSameDayEvents
7187
} else {
72-
nextSameDayEvents.subList(0, indexOfFirstDifferentEventType)
88+
nextSameDayEvents.subList(0, indexOfFirstDifferentEvent)
7389
}
74-
val filteredSameTypeEvents = sameTypeEvents.filter {
90+
val filteredSimilarEvents = similarEvents.filter {
7591
shouldShowEvent(
7692
timelineEvent = it,
7793
highlightedEventId = eventIdToHighlight,
7894
isFromThreadTimeline = isFromThreadTimeline,
7995
rootThreadEventId = rootThreadEventId
8096
)
8197
}
82-
if (filteredSameTypeEvents.size < minSize) {
83-
return emptyList()
84-
}
85-
return filteredSameTypeEvents
98+
return if (filteredSimilarEvents.size < minSize) emptyList() else filteredSimilarEvents
8699
}
87100

88101
/**
89-
* @param timelineEvents the events to search in
102+
* @param timelineEvents the events to search in, sorted from newer event to oldest event
90103
* @param index the index to start computing (inclusive)
91104
* @param minSize the minimum number of same type events to have sequentially, otherwise will return an empty list
92105
* @param eventIdToHighlight used to compute visibility
@@ -107,7 +120,44 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
107120
return prevSub
108121
.reversed()
109122
.let {
110-
nextSameTypeEvents(it, 0, minSize, eventIdToHighlight, rootThreadEventId, isFromThreadTimeline)
123+
nextEventsUntil(it, 0, minSize, eventIdToHighlight, rootThreadEventId, isFromThreadTimeline, object : PredicateToStopSearch {
124+
override fun shouldStopSearch(oldEvent: Event, newEvent: Event): Boolean {
125+
return oldEvent.getClearType() != newEvent.getClearType()
126+
}
127+
})
128+
}
129+
}
130+
131+
/**
132+
* @param timelineEvents the events to search in, sorted from newer event to oldest event
133+
* @param index the index to start computing (inclusive)
134+
* @param minSize the minimum number of same type events to have sequentially, otherwise will return an empty list
135+
* @param eventIdToHighlight used to compute visibility
136+
* @param rootThreadEventId the root thread eventId
137+
* @param isFromThreadTimeline true if the timeline is a thread
138+
*
139+
* @return a list of timeline events which are all redacted following the prev direction.
140+
*/
141+
fun prevRedactedEvents(
142+
timelineEvents: List<TimelineEvent>,
143+
index: Int,
144+
minSize: Int,
145+
eventIdToHighlight: String?,
146+
rootThreadEventId: String?,
147+
isFromThreadTimeline: Boolean
148+
): List<TimelineEvent> {
149+
val prevSub = timelineEvents
150+
.subList(0, index + 1)
151+
// Ensure to not take the REDACTION events into account
152+
.filter { it.root.getClearType() != EventType.REDACTION }
153+
return prevSub
154+
.reversed()
155+
.let {
156+
nextEventsUntil(it, 0, minSize, eventIdToHighlight, rootThreadEventId, isFromThreadTimeline, object : PredicateToStopSearch {
157+
override fun shouldStopSearch(oldEvent: Event, newEvent: Event): Boolean {
158+
return oldEvent.isRedacted() && !newEvent.isRedacted()
159+
}
160+
})
111161
}
112162
}
113163

vector/src/main/res/values/strings.xml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1608,7 +1608,7 @@
16081608
<string name="message_view_reaction">View Reactions</string>
16091609
<string name="reactions">Reactions</string>
16101610

1611-
<string name="event_redacted">Message deleted</string>
1611+
<string name="event_redacted">Message removed</string>
16121612
<string name="settings_show_redacted">Show removed messages</string>
16131613
<string name="settings_show_redacted_summary">Show a placeholder for removed messages</string>
16141614
<string name="event_redacted_by_user_reason">Event deleted by user</string>
@@ -3166,4 +3166,8 @@
31663166
<string name="live_location_labs_promotion_description">Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room.</string>
31673167
<string name="live_location_labs_promotion_switch_title">Enable location sharing</string>
31683168

3169+
<plurals name="room_removed_messages">
3170+
<item quantity="one">%d message removed</item>
3171+
<item quantity="other">%d messages removed</item>
3172+
</plurals>
31693173
</resources>

0 commit comments

Comments
 (0)