BayernMessenger/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt

350 lines
15 KiB
Kotlin

/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline
import android.os.Handler
import android.os.Looper
import android.view.View
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.core.date.VectorDateFormatter
import im.vector.riotx.core.epoxy.LoadingItem_
import im.vector.riotx.core.extensions.localDateTime
import im.vector.riotx.core.resources.UserPreferencesProvider
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.factory.TimelineItemFactory
import im.vector.riotx.features.home.room.detail.timeline.helper.*
import im.vector.riotx.features.home.room.detail.timeline.item.DaySeparatorItem
import im.vector.riotx.features.home.room.detail.timeline.item.DaySeparatorItem_
import im.vector.riotx.features.home.room.detail.timeline.item.MergedHeaderItem
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.media.ImageContentRenderer
import im.vector.riotx.features.media.VideoContentRenderer
import org.threeten.bp.LocalDateTime
import javax.inject.Inject
class TimelineEventController @Inject constructor(private val dateFormatter: VectorDateFormatter,
private val timelineItemFactory: TimelineItemFactory,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val avatarRenderer: AvatarRenderer,
@TimelineEventControllerHandler
private val backgroundHandler: Handler,
userPreferencesProvider: UserPreferencesProvider
) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener {
interface Callback : BaseCallback, ReactionPillCallback, AvatarCallback, UrlClickCallback, ReadReceiptsCallback {
fun onEventVisible(event: TimelineEvent)
fun onRoomCreateLinkClicked(url: String)
fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View)
fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View)
fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View)
fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent)
fun onAudioMessageClicked(messageAudioContent: MessageAudioContent)
fun onEditedDecorationClicked(informationData: MessageInformationData)
}
interface ReactionPillCallback {
fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean)
fun onLongClickOnReactionPill(informationData: MessageInformationData, reaction: String)
}
interface BaseCallback {
fun onEventCellClicked(informationData: MessageInformationData, messageContent: MessageContent?, view: View)
fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent?, view: View): Boolean
}
interface AvatarCallback {
fun onAvatarClicked(informationData: MessageInformationData)
fun onMemberNameClicked(informationData: MessageInformationData)
}
interface ReadReceiptsCallback {
fun onReadReceiptsClicked(informationData: MessageInformationData)
}
interface UrlClickCallback {
fun onUrlClicked(url: String): Boolean
fun onUrlLongClicked(url: String): Boolean
}
private val collapsedEventIds = linkedSetOf<Long>()
private val mergeItemCollapseStates = HashMap<Long, Boolean>()
private val modelCache = arrayListOf<CacheItemData?>()
private var currentSnapshot: List<TimelineEvent> = emptyList()
private var inSubmitList: Boolean = false
private var timeline: Timeline? = null
var callback: Callback? = null
private val listUpdateCallback = object : ListUpdateCallback {
override fun onChanged(position: Int, count: Int, payload: Any?) {
synchronized(modelCache) {
assertUpdateCallbacksAllowed()
(position until (position + count)).forEach {
modelCache[it] = null
}
requestModelBuild()
}
}
override fun onMoved(fromPosition: Int, toPosition: Int) {
synchronized(modelCache) {
assertUpdateCallbacksAllowed()
val model = modelCache.removeAt(fromPosition)
modelCache.add(toPosition, model)
requestModelBuild()
}
}
override fun onInserted(position: Int, count: Int) {
synchronized(modelCache) {
assertUpdateCallbacksAllowed()
(0 until count).forEach {
modelCache.add(position, null)
}
requestModelBuild()
}
}
override fun onRemoved(position: Int, count: Int) {
synchronized(modelCache) {
assertUpdateCallbacksAllowed()
(0 until count).forEach {
modelCache.removeAt(position)
}
requestModelBuild()
}
}
}
private val showHiddenEvents = userPreferencesProvider.shouldShowHiddenEvents()
init {
requestModelBuild()
}
fun setTimeline(timeline: Timeline?, eventIdToHighlight: String?) {
if (this.timeline != timeline) {
this.timeline = timeline
this.timeline?.listener = this
// Clear cache
synchronized(modelCache) {
for (i in 0 until modelCache.size) {
modelCache[i] = null
}
}
}
if (this.eventIdToHighlight != eventIdToHighlight) {
// Clear cache to force a refresh
synchronized(modelCache) {
for (i in 0 until modelCache.size) {
if (modelCache[i]?.eventId == eventIdToHighlight
|| modelCache[i]?.eventId == this.eventIdToHighlight) {
modelCache[i] = null
}
}
}
this.eventIdToHighlight = eventIdToHighlight
requestModelBuild()
}
}
private var eventIdToHighlight: String? = null
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
timelineMediaSizeProvider.recyclerView = recyclerView
}
override fun buildModels() {
val loaderAdded = LoadingItem_()
.id("forward_loading_item")
.addWhen(Timeline.Direction.FORWARDS)
val timelineModels = getModels()
add(timelineModels)
// Avoid displaying two loaders if there is no elements between them
if (!loaderAdded || timelineModels.isNotEmpty()) {
LoadingItem_()
.id("backward_loading_item")
.addWhen(Timeline.Direction.BACKWARDS)
}
}
// Timeline.LISTENER ***************************************************************************
override fun onUpdated(snapshot: List<TimelineEvent>) {
submitSnapshot(snapshot)
}
private fun submitSnapshot(newSnapshot: List<TimelineEvent>) {
backgroundHandler.post {
inSubmitList = true
val diffCallback = TimelineEventDiffUtilCallback(currentSnapshot, newSnapshot)
currentSnapshot = newSnapshot
val diffResult = DiffUtil.calculateDiff(diffCallback)
diffResult.dispatchUpdatesTo(listUpdateCallback)
inSubmitList = false
}
}
private fun assertUpdateCallbacksAllowed() {
require(inSubmitList || Looper.myLooper() == backgroundHandler.looper)
}
private fun getModels(): List<EpoxyModel<*>> {
synchronized(modelCache) {
(0 until modelCache.size).forEach { position ->
// Should be build if not cached or if cached but contains mergedHeader or formattedDay
// We then are sure we always have items up to date.
if (modelCache[position] == null
|| modelCache[position]?.mergedHeaderModel != null
|| modelCache[position]?.formattedDayModel != null) {
modelCache[position] = buildItemModels(position, currentSnapshot)
}
}
return modelCache
.map {
val eventModel = if (it == null || collapsedEventIds.contains(it.localId)) {
null
} else {
it.eventModel
}
listOf(eventModel, it?.mergedHeaderModel, it?.formattedDayModel)
}
.flatten()
.filterNotNull()
}
}
private fun buildItemModels(currentPosition: Int, items: List<TimelineEvent>): CacheItemData {
val event = items[currentPosition]
val nextEvent = items.nextDisplayableEvent(currentPosition, showHiddenEvents)
val date = event.root.localDateTime()
val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, callback).also {
it.id(event.localId)
it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event))
}
val mergedHeaderModel = buildMergedHeaderItem(event, nextEvent, items, addDaySeparator, currentPosition)
val daySeparatorItem = buildDaySeparatorItem(addDaySeparator, date)
return CacheItemData(event.localId, event.root.eventId, eventModel, mergedHeaderModel, daySeparatorItem)
}
private fun buildDaySeparatorItem(addDaySeparator: Boolean, date: LocalDateTime): DaySeparatorItem? {
return if (addDaySeparator) {
val formattedDay = dateFormatter.formatMessageDay(date)
DaySeparatorItem_().formattedDay(formattedDay).id(formattedDay)
} else {
null
}
}
// TODO Phase 3 Handle the case where the eventId we have to highlight is merged
private fun buildMergedHeaderItem(event: TimelineEvent,
nextEvent: TimelineEvent?,
items: List<TimelineEvent>,
addDaySeparator: Boolean,
currentPosition: Int): MergedHeaderItem? {
return if (!event.canBeMerged() || (nextEvent?.root?.getClearType() == event.root.getClearType() && !addDaySeparator)) {
null
} else {
val prevSameTypeEvents = items.prevSameTypeEvents(currentPosition, 2)
if (prevSameTypeEvents.isEmpty()) {
null
} else {
val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed()
val mergedData = mergedEvents.map { mergedEvent ->
val senderAvatar = mergedEvent.senderAvatar()
val senderName = mergedEvent.senderName()
MergedHeaderItem.Data(
userId = mergedEvent.root.senderId ?: "",
avatarUrl = senderAvatar,
memberName = senderName ?: "",
eventId = mergedEvent.localId
)
}
val mergedEventIds = mergedEvents.map { it.localId }
// 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()
val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey)
?: true
val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState }
if (isCollapsed) {
collapsedEventIds.addAll(mergedEventIds)
} else {
collapsedEventIds.removeAll(mergedEventIds)
}
val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() }
MergedHeaderItem(isCollapsed, mergeId, mergedData, avatarRenderer) {
mergeItemCollapseStates[event.localId] = it
requestModelBuild()
}.also {
it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents))
}
}
}
}
/**
* Return true if added
*/
private fun LoadingItem_.addWhen(direction: Timeline.Direction): Boolean {
val shouldAdd = timeline?.hasMoreToLoad(direction) ?: false
addIf(shouldAdd, this@TimelineEventController)
return shouldAdd
}
fun searchPositionOfEvent(eventId: String): Int? {
synchronized(modelCache) {
// Search in the cache
modelCache.forEachIndexed { idx, cacheItemData ->
if (cacheItemData?.eventId == eventId) {
return idx
}
}
return null
}
}
}
private data class CacheItemData(
val localId: Long,
val eventId: String?,
val eventModel: EpoxyModel<*>? = null,
val mergedHeaderModel: MergedHeaderItem? = null,
val formattedDayModel: DaySeparatorItem? = null
)