Merge pull request #6501 from vector-im/feature/mna/collapse-deleted-events

[Timeline] - Collapse redacted events (PSG-523)
This commit is contained in:
Maxime NATUREL 2022-07-19 16:39:45 +02:00 committed by GitHub
commit b08337e3a7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 179 additions and 42 deletions

1
changelog.d/6487.feature Normal file
View file

@ -0,0 +1 @@
[Timeline] - Collapse redacted events

View file

@ -202,7 +202,7 @@ data class Event(
* It will return a decrypted text message or an empty string otherwise.
*/
fun getDecryptedTextSummary(): String? {
if (isRedacted()) return "Message Deleted"
if (isRedacted()) return "Message removed"
val text = getDecryptedValue() ?: run {
if (isPoll()) {
return getPollQuestion() ?: "created a poll."

View file

@ -24,7 +24,6 @@ import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.MergedTimelineEventVisibilityStateChangedListener
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper
import im.vector.app.features.home.room.detail.timeline.helper.canBeMerged
import im.vector.app.features.home.room.detail.timeline.helper.isRoomConfiguration
import im.vector.app.features.home.room.detail.timeline.item.BasedMergedItem
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
import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.content.EncryptionEventContent
import org.matrix.android.sdk.api.session.events.model.toModel
@ -53,6 +53,7 @@ class MergedHeaderItemFactory @Inject constructor(
private val timelineEventVisibilityHelper: TimelineEventVisibilityHelper
) {
private val mergeableEventTypes = listOf(EventType.STATE_ROOM_MEMBER, EventType.STATE_ROOM_SERVER_ACL)
private val collapsedEventIds = linkedSetOf<Long>()
private val mergeItemCollapseStates = HashMap<Long, Boolean>()
@ -78,19 +79,65 @@ class MergedHeaderItemFactory @Inject constructor(
callback: TimelineEventController.Callback?,
requestModelBuild: () -> Unit
): BasedMergedItem<*>? {
return if (nextEvent?.root?.getClearType() == EventType.STATE_ROOM_CREATE &&
event.isRoomConfiguration(nextEvent.root.getClearContent()?.toModel<RoomCreateContent>()?.creator)) {
// It's the first item before room.create
// Collapse all room configuration events
buildRoomCreationMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
} else if (!event.canBeMerged() || (nextEvent?.root?.getClearType() == event.root.getClearType() && !addDaySeparator)) {
null
} else {
buildMembershipEventsMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
return when {
isStartOfRoomCreationSummary(event, nextEvent) ->
buildRoomCreationMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
isStartOfSameTypeEventsSummary(event, nextEvent, addDaySeparator) ->
buildSameTypeEventsMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
isStartOfRedactedEventsSummary(event, items, currentPosition, addDaySeparator) ->
buildRedactedEventsMergedSummary(currentPosition, items, partialState, event, eventIdToHighlight, requestModelBuild, callback)
else -> null
}
}
private fun buildMembershipEventsMergedSummary(
/**
* @param event the main timeline event
* @param nextEvent is an older event than event
*/
private fun isStartOfRoomCreationSummary(
event: TimelineEvent,
nextEvent: TimelineEvent?,
): Boolean {
// It's the first item before room.create
// Collapse all room configuration events
return nextEvent?.root?.getClearType() == EventType.STATE_ROOM_CREATE &&
event.isRoomConfiguration(nextEvent.root.getClearContent()?.toModel<RoomCreateContent>()?.creator)
}
/**
* @param event the main timeline event
* @param nextEvent is an older event than event
* @param addDaySeparator true to add a day separator
*/
private fun isStartOfSameTypeEventsSummary(
event: TimelineEvent,
nextEvent: TimelineEvent?,
addDaySeparator: Boolean,
): Boolean {
return event.root.getClearType() in mergeableEventTypes &&
(nextEvent?.root?.getClearType() != event.root.getClearType() || addDaySeparator)
}
/**
* @param event the main timeline event
* @param items all known items, sorted from newer event to oldest event
* @param currentPosition the current position
* @param addDaySeparator true to add a day separator
*/
private fun isStartOfRedactedEventsSummary(
event: TimelineEvent,
items: List<TimelineEvent>,
currentPosition: Int,
addDaySeparator: Boolean,
): Boolean {
val nextNonRedactionEvent = items
.subList(fromIndex = currentPosition + 1, toIndex = items.size)
.find { it.root.getClearType() != EventType.REDACTION }
return event.root.isRedacted() &&
(!nextNonRedactionEvent?.root?.isRedacted().orFalse() || addDaySeparator)
}
private fun buildSameTypeEventsMergedSummary(
currentPosition: Int,
items: List<TimelineEvent>,
partialState: TimelineEventController.PartialState,
@ -102,11 +149,42 @@ class MergedHeaderItemFactory @Inject constructor(
val mergedEvents = timelineEventVisibilityHelper.prevSameTypeEvents(
items,
currentPosition,
2,
MIN_NUMBER_OF_MERGED_EVENTS,
eventIdToHighlight,
partialState.rootThreadEventId,
partialState.isFromThreadTimeline()
)
return buildSimilarEventsMergedSummary(mergedEvents, partialState, event, eventIdToHighlight, requestModelBuild, callback)
}
private fun buildRedactedEventsMergedSummary(
currentPosition: Int,
items: List<TimelineEvent>,
partialState: TimelineEventController.PartialState,
event: TimelineEvent,
eventIdToHighlight: String?,
requestModelBuild: () -> Unit,
callback: TimelineEventController.Callback?
): MergedSimilarEventsItem_? {
val mergedEvents = timelineEventVisibilityHelper.prevRedactedEvents(
items,
currentPosition,
MIN_NUMBER_OF_MERGED_EVENTS,
eventIdToHighlight,
partialState.rootThreadEventId,
partialState.isFromThreadTimeline()
)
return buildSimilarEventsMergedSummary(mergedEvents, partialState, event, eventIdToHighlight, requestModelBuild, callback)
}
private fun buildSimilarEventsMergedSummary(
mergedEvents: List<TimelineEvent>,
partialState: TimelineEventController.PartialState,
event: TimelineEvent,
eventIdToHighlight: String?,
requestModelBuild: () -> Unit,
callback: TimelineEventController.Callback?
): MergedSimilarEventsItem_? {
return if (mergedEvents.isEmpty()) {
null
} else {
@ -127,7 +205,7 @@ class MergedHeaderItemFactory @Inject constructor(
)
mergedData.add(data)
}
val mergedEventIds = mergedEvents.map { it.localId }
val mergedEventIds = mergedEvents.map { it.localId }.toSet()
// We try to find if one of the item id were used as mergeItemCollapseStates key
// => handle case where paginating from mergeable events and we get more
val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull()
@ -140,12 +218,7 @@ class MergedHeaderItemFactory @Inject constructor(
collapsedEventIds.removeAll(mergedEventIds)
}
val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() }
val summaryTitleResId = when (event.root.getClearType()) {
EventType.STATE_ROOM_MEMBER -> R.plurals.membership_changes
EventType.STATE_ROOM_SERVER_ACL -> R.plurals.notice_room_server_acl_changes
else -> null
}
summaryTitleResId?.let { summaryTitle ->
getSummaryTitleResId(event.root)?.let { summaryTitle ->
val attributes = MergedSimilarEventsItem.Attributes(
summaryTitleResId = summaryTitle,
isCollapsed = isCollapsed,
@ -168,6 +241,16 @@ class MergedHeaderItemFactory @Inject constructor(
}
}
private fun getSummaryTitleResId(event: Event): Int? {
val type = event.getClearType()
return when {
type == EventType.STATE_ROOM_MEMBER -> R.plurals.membership_changes
type == EventType.STATE_ROOM_SERVER_ACL -> R.plurals.notice_room_server_acl_changes
event.isRedacted() -> R.plurals.room_removed_messages
else -> null
}
}
private fun buildRoomCreationMergedSummary(
currentPosition: Int,
items: List<TimelineEvent>,
@ -191,7 +274,7 @@ class MergedHeaderItemFactory @Inject constructor(
tmpPos--
prevEvent = items.getOrNull(tmpPos)
}
return if (mergedEvents.size > 2) {
return if (mergedEvents.size > MIN_NUMBER_OF_MERGED_EVENTS) {
var highlighted = false
val mergedData = ArrayList<BasedMergedItem.Data>(mergedEvents.size)
mergedEvents.reversed()
@ -264,4 +347,8 @@ class MergedHeaderItemFactory @Inject constructor(
fun isCollapsed(localId: Long): Boolean {
return collapsedEventIds.contains(localId)
}
companion object {
private const val MIN_NUMBER_OF_MERGED_EVENTS = 2
}
}

View file

@ -54,11 +54,6 @@ object TimelineDisplayableEvents {
) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
}
fun TimelineEvent.canBeMerged(): Boolean {
return root.getClearType() == EventType.STATE_ROOM_MEMBER ||
root.getClearType() == EventType.STATE_ROOM_SERVER_ACL
}
fun TimelineEvent.isRoomConfiguration(roomCreatorUserId: String?): Boolean {
return root.isStateEvent() && when (root.getClearType()) {
EventType.STATE_ROOM_GUEST_ACCESS,

View file

@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.helper
import im.vector.app.core.extensions.localDateTime
import im.vector.app.core.resources.UserPreferencesProvider
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType
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
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject
class TimelineEventVisibilityHelper @Inject constructor(private val userPreferencesProvider: UserPreferencesProvider) {
class TimelineEventVisibilityHelper @Inject constructor(
private val userPreferencesProvider: UserPreferencesProvider,
) {
private interface PredicateToStopSearch {
/**
* Indicate whether a search on events should stop by comparing 2 given successive events.
* @param oldEvent the oldest event between the 2 events to compare
* @param newEvent the more recent event between the 2 events to compare
*/
fun shouldStopSearch(oldEvent: Event, newEvent: Event): Boolean
}
/**
* @param timelineEvents the events to search in
* @param timelineEvents the events to search in, sorted from oldest event to newer event
* @param index the index to start computing (inclusive)
* @param minSize the minimum number of same type events to have sequentially, otherwise will return an empty list
* @param eventIdToHighlight used to compute visibility
* @param rootThreadEventId the root thread event id if in a thread timeline
* @param isFromThreadTimeline true if the timeline is a thread
* @param predicateToStop events are taken until this condition is met
*
* @return a list of timeline events which have sequentially the same type following the next direction.
* @return a list of timeline events which meet sequentially the same criteria following the next direction.
*/
private fun nextSameTypeEvents(
private fun nextEventsUntil(
timelineEvents: List<TimelineEvent>,
index: Int,
minSize: Int,
eventIdToHighlight: String?,
rootThreadEventId: String?,
isFromThreadTimeline: Boolean
isFromThreadTimeline: Boolean,
predicateToStop: PredicateToStopSearch
): List<TimelineEvent> {
if (index >= timelineEvents.size - 1) {
return emptyList()
@ -65,13 +79,15 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
} else {
nextSubList.subList(0, indexOfNextDay)
}
val indexOfFirstDifferentEventType = nextSameDayEvents.indexOfFirst { it.root.getClearType() != timelineEvent.root.getClearType() }
val sameTypeEvents = if (indexOfFirstDifferentEventType == -1) {
val indexOfFirstDifferentEvent = nextSameDayEvents.indexOfFirst {
predicateToStop.shouldStopSearch(oldEvent = timelineEvent.root, newEvent = it.root)
}
val similarEvents = if (indexOfFirstDifferentEvent == -1) {
nextSameDayEvents
} else {
nextSameDayEvents.subList(0, indexOfFirstDifferentEventType)
nextSameDayEvents.subList(0, indexOfFirstDifferentEvent)
}
val filteredSameTypeEvents = sameTypeEvents.filter {
val filteredSimilarEvents = similarEvents.filter {
shouldShowEvent(
timelineEvent = it,
highlightedEventId = eventIdToHighlight,
@ -79,14 +95,11 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
rootThreadEventId = rootThreadEventId
)
}
if (filteredSameTypeEvents.size < minSize) {
return emptyList()
}
return filteredSameTypeEvents
return if (filteredSimilarEvents.size < minSize) emptyList() else filteredSimilarEvents
}
/**
* @param timelineEvents the events to search in
* @param timelineEvents the events to search in, sorted from newer event to oldest event
* @param index the index to start computing (inclusive)
* @param minSize the minimum number of same type events to have sequentially, otherwise will return an empty list
* @param eventIdToHighlight used to compute visibility
@ -107,7 +120,44 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
return prevSub
.reversed()
.let {
nextSameTypeEvents(it, 0, minSize, eventIdToHighlight, rootThreadEventId, isFromThreadTimeline)
nextEventsUntil(it, 0, minSize, eventIdToHighlight, rootThreadEventId, isFromThreadTimeline, object : PredicateToStopSearch {
override fun shouldStopSearch(oldEvent: Event, newEvent: Event): Boolean {
return oldEvent.getClearType() != newEvent.getClearType()
}
})
}
}
/**
* @param timelineEvents the events to search in, sorted from newer event to oldest event
* @param index the index to start computing (inclusive)
* @param minSize the minimum number of same type events to have sequentially, otherwise will return an empty list
* @param eventIdToHighlight used to compute visibility
* @param rootThreadEventId the root thread eventId
* @param isFromThreadTimeline true if the timeline is a thread
*
* @return a list of timeline events which are all redacted following the prev direction.
*/
fun prevRedactedEvents(
timelineEvents: List<TimelineEvent>,
index: Int,
minSize: Int,
eventIdToHighlight: String?,
rootThreadEventId: String?,
isFromThreadTimeline: Boolean
): List<TimelineEvent> {
val prevSub = timelineEvents
.subList(0, index + 1)
// Ensure to not take the REDACTION events into account
.filter { it.root.getClearType() != EventType.REDACTION }
return prevSub
.reversed()
.let {
nextEventsUntil(it, 0, minSize, eventIdToHighlight, rootThreadEventId, isFromThreadTimeline, object : PredicateToStopSearch {
override fun shouldStopSearch(oldEvent: Event, newEvent: Event): Boolean {
return oldEvent.isRedacted() && !newEvent.isRedacted()
}
})
}
}

View file

@ -1608,7 +1608,7 @@
<string name="message_view_reaction">View Reactions</string>
<string name="reactions">Reactions</string>
<string name="event_redacted">Message deleted</string>
<string name="event_redacted">Message removed</string>
<string name="settings_show_redacted">Show removed messages</string>
<string name="settings_show_redacted_summary">Show a placeholder for removed messages</string>
<string name="event_redacted_by_user_reason">Event deleted by user</string>
@ -3166,4 +3166,8 @@
<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>
<string name="live_location_labs_promotion_switch_title">Enable location sharing</string>
<plurals name="room_removed_messages">
<item quantity="one">%d message removed</item>
<item quantity="other">%d messages removed</item>
</plurals>
</resources>