/* * 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.home.room.detail.timeline.item.ReadReceiptData 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(readReceipts: List) } interface UrlClickCallback { fun onUrlClicked(url: String): Boolean fun onUrlLongClicked(url: String): Boolean } private val collapsedEventIds = linkedSetOf() private val mergeItemCollapseStates = HashMap() private val modelCache = arrayListOf() private var currentSnapshot: List = 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) { submitSnapshot(snapshot) } private fun submitSnapshot(newSnapshot: List) { 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> { 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): 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, 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 )