From b3e2eca43d8923eba8de3b1ab1c60e7b3cf81337 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 17 Apr 2019 22:37:25 +0200 Subject: [PATCH] Timeline : start to handle merging room member events. Need to get better perf. --- .../home/room/detail/RoomDetailActions.kt | 2 +- .../home/room/detail/RoomDetailFragment.kt | 11 +-- .../home/room/detail/RoomDetailViewModel.kt | 18 ++-- .../timeline/TimelineEventController.kt | 94 +++++++++++------- .../timeline/factory/RoomMemberItemFactory.kt | 21 ++-- .../timeline/factory/TimelineItemFactory.kt | 61 ++++++++---- .../helper/TimelineDisplayableEvents.kt | 24 ++++- ...lineEventVisibilityStateChangedListener.kt | 36 +++++++ .../timeline/item/RoomMemberMergedItem.kt | 99 +++++++++++++++++++ ...item_timeline_event_room_member_merged.xml | 73 ++++++++++++++ .../vector_message_merge_avatar_list.xml | 57 +++++++++++ 11 files changed, 413 insertions(+), 83 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/RoomMemberMergedItem.kt create mode 100644 vector/src/main/res/layout/item_timeline_event_room_member_merged.xml create mode 100644 vector/src/main/res/layout/vector_message_merge_avatar_list.xml diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt index 38c436b5..3b595077 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt @@ -25,7 +25,7 @@ sealed class RoomDetailActions { data class SendMessage(val text: String) : RoomDetailActions() data class SendMedia(val mediaFiles: List) : RoomDetailActions() object IsDisplayed : RoomDetailActions() - data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions() + data class EventsDisplayed(val events: List) : RoomDetailActions() data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions() } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt index 66babeac..f2922d43 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt @@ -50,12 +50,7 @@ import im.vector.riotredesign.core.extensions.observeEvent import im.vector.riotredesign.core.glide.GlideApp import im.vector.riotredesign.core.platform.ToolbarConfigurable import im.vector.riotredesign.core.platform.VectorBaseFragment -import im.vector.riotredesign.core.utils.PERMISSIONS_FOR_TAKING_PHOTO -import im.vector.riotredesign.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA -import im.vector.riotredesign.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA -import im.vector.riotredesign.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA -import im.vector.riotredesign.core.utils.checkPermissions -import im.vector.riotredesign.core.utils.openCamera +import im.vector.riotredesign.core.utils.* import im.vector.riotredesign.features.autocomplete.command.AutocompleteCommandPresenter import im.vector.riotredesign.features.autocomplete.command.CommandAutocompletePolicy import im.vector.riotredesign.features.autocomplete.user.AutocompleteUserPresenter @@ -381,8 +376,8 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac homePermalinkHandler.launch(url) } - override fun onEventVisible(event: TimelineEvent) { - roomDetailViewModel.process(RoomDetailActions.EventDisplayed(event)) + override fun onEventsVisible(events: List) { + roomDetailViewModel.process(RoomDetailActions.EventsDisplayed(events)) } override fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View) { diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt index d2662d79..da2754cf 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt @@ -44,7 +44,7 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, private val room = session.getRoom(initialState.roomId)!! private val roomId = initialState.roomId private val eventId = initialState.eventId - private val displayedEventsObservable = BehaviorRelay.create() + private val displayedEventsObservable = BehaviorRelay.create() private val timeline = room.createTimeline(eventId, TimelineDisplayableEvents.DISPLAYABLE_TYPES) companion object : MvRxViewModelFactory { @@ -69,11 +69,11 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, fun process(action: RoomDetailActions) { when (action) { - is RoomDetailActions.SendMessage -> handleSendMessage(action) - is RoomDetailActions.IsDisplayed -> handleIsDisplayed() - is RoomDetailActions.SendMedia -> handleSendMedia(action) - is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action) - is RoomDetailActions.LoadMore -> handleLoadMore(action) + is RoomDetailActions.SendMessage -> handleSendMessage(action) + is RoomDetailActions.IsDisplayed -> handleIsDisplayed() + is RoomDetailActions.SendMedia -> handleSendMedia(action) + is RoomDetailActions.EventsDisplayed -> handleEventDisplayed(action) + is RoomDetailActions.LoadMore -> handleLoadMore(action) } } @@ -196,7 +196,7 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, room.sendMedias(attachments) } - private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) { + private fun handleEventDisplayed(action: RoomDetailActions.EventsDisplayed) { displayedEventsObservable.accept(action) } @@ -215,8 +215,8 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, .buffer(1, TimeUnit.SECONDS) .filter { it.isNotEmpty() } .subscribeBy(onNext = { actions -> - val mostRecentEvent = actions.maxBy { it.event.displayIndex } - mostRecentEvent?.event?.root?.eventId?.let { eventId -> + val mostRecentEvent = actions.map { it.events }.flatten().maxBy { it.displayIndex } + mostRecentEvent?.root?.eventId?.let { eventId -> room.setReadReceipt(eventId, callback = object : MatrixCallback {}) } }) diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/TimelineEventController.kt index 2d48766b..010ab48a 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/TimelineEventController.kt @@ -24,7 +24,6 @@ import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.EpoxyModel -import com.airbnb.epoxy.VisibilityState import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent import im.vector.matrix.android.api.session.room.model.message.MessageFileContent import im.vector.matrix.android.api.session.room.model.message.MessageImageContent @@ -32,15 +31,18 @@ import im.vector.matrix.android.api.session.room.model.message.MessageVideoConte import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotredesign.core.epoxy.LoadingItemModel_ -import im.vector.riotredesign.core.epoxy.VectorEpoxyModel import im.vector.riotredesign.core.extensions.localDateTime import im.vector.riotredesign.features.home.room.detail.timeline.factory.TimelineItemFactory import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineAsyncHelper import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback +import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider +import im.vector.riotredesign.features.home.room.detail.timeline.helper.canBeMerged import im.vector.riotredesign.features.home.room.detail.timeline.helper.nextDisplayableEvent +import im.vector.riotredesign.features.home.room.detail.timeline.helper.nextSameTypeEvents import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem_ +import im.vector.riotredesign.features.home.room.detail.timeline.item.RoomMemberMergedItem import im.vector.riotredesign.features.media.ImageContentRenderer import im.vector.riotredesign.features.media.VideoContentRenderer @@ -51,7 +53,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener { interface Callback { - fun onEventVisible(event: TimelineEvent) + fun onEventsVisible(events: List) fun onUrlClicked(url: String) fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View) fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) @@ -59,7 +61,8 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) } - private val modelCache = arrayListOf>>() + + private val modelCache = arrayListOf() private var currentSnapshot: List = emptyList() private var inSubmitList: Boolean = false private var timeline: Timeline? = null @@ -72,7 +75,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, override fun onChanged(position: Int, count: Int, payload: Any?) { assertUpdateCallbacksAllowed() (position until (position + count)).forEach { - modelCache[it] = emptyList() + modelCache[it] = null } requestModelBuild() } @@ -88,11 +91,18 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, @Synchronized override fun onInserted(position: Int, count: Int) { assertUpdateCallbacksAllowed() - if (modelCache.isNotEmpty() && position == modelCache.size) { - modelCache[position - 1] = emptyList() + // When adding backwards we need to clear some events + if (position == modelCache.size) { + val previousCachedModel = modelCache.getOrNull(position - 1) + if (previousCachedModel != null) { + val numberOfMergedEvents = previousCachedModel.numberOfMergedEvents + for (i in 0..numberOfMergedEvents) { + modelCache[position - 1 - i] = null + } + } } (0 until count).forEach { - modelCache.add(position, emptyList()) + modelCache.add(position, null) } requestModelBuild() } @@ -161,53 +171,63 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter, @Synchronized private fun getModels(): List> { (0 until modelCache.size).forEach { position -> - if (modelCache[position].isEmpty()) { - modelCache[position] = buildItemModels(position, currentSnapshot) + if (modelCache[position] == null) { + buildAndCacheItemsAt(position) } } - return modelCache.flatten() + return modelCache + .map { listOf(it?.eventModel, it?.formattedDayModel) } + .flatten() + .filterNotNull() } - private fun buildItemModels(currentPosition: Int, items: List): List> { - val epoxyModels = ArrayList>() + private fun buildAndCacheItemsAt(position: Int) { + val buildItemModelsResult = buildItemModels(position, currentSnapshot) + modelCache[position] = buildItemModelsResult + val prevResult = modelCache.getOrNull(position + 1) + if (prevResult != null && prevResult.eventModel is RoomMemberMergedItem && buildItemModelsResult.eventModel is RoomMemberMergedItem) { + buildItemModelsResult.eventModel.isCollapsed = prevResult.eventModel.isCollapsed + } + for (skipItemPosition in 0 until buildItemModelsResult.numberOfMergedEvents) { + val dumbModelsResult = CacheItemData(numberOfMergedEvents = buildItemModelsResult.numberOfMergedEvents) + modelCache[position + 1 + skipItemPosition] = dumbModelsResult + } + } + + private fun buildItemModels(currentPosition: Int, items: List): CacheItemData { val event = items[currentPosition] - val nextEvent = items.nextDisplayableEvent(currentPosition) + val mergeableEvents = if (event.canBeMerged()) items.nextSameTypeEvents(currentPosition, minSize = 2) else emptyList() + val mergedEvents = listOf(event) + mergeableEvents + val nextDisplayableEvent = items.nextDisplayableEvent(currentPosition + mergeableEvents.size) val date = event.root.localDateTime() - val nextDate = nextEvent?.root?.localDateTime() + val nextDate = nextDisplayableEvent?.root?.localDateTime() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() + val visibilityStateChangedListener = TimelineEventVisibilityStateChangedListener(callback, mergedEvents) + val epoxyModelId = mergedEvents.joinToString(separator = "_") { it.localId } - timelineItemFactory.create(event, nextEvent, callback).also { - it.id(event.localId) - it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) - epoxyModels.add(it) + val eventModel = timelineItemFactory.create(event, mergeableEvents, nextDisplayableEvent, callback, visibilityStateChangedListener).also { + it.id(epoxyModelId) } - if (addDaySeparator) { + val daySeparatorItem = if (addDaySeparator) { val formattedDay = dateFormatter.formatMessageDay(date) - val daySeparatorItem = DaySeparatorItem_().formattedDay(formattedDay).id(formattedDay) - epoxyModels.add(daySeparatorItem) + DaySeparatorItem_().formattedDay(formattedDay).id(formattedDay) + } else { + null } - return epoxyModels + return CacheItemData(eventModel, daySeparatorItem, mergeableEvents.size) } private fun LoadingItemModel_.addWhen(direction: Timeline.Direction) { - val shouldAdd = timeline?.let { - it.hasMoreToLoad(direction) - } ?: false + val shouldAdd = timeline?.hasMoreToLoad(direction) ?: false addIf(shouldAdd, this@TimelineEventController) } } -private class TimelineEventVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?, - private val event: TimelineEvent) - : VectorEpoxyModel.OnVisibilityStateChangedListener { +private data class CacheItemData( + val eventModel: EpoxyModel<*>? = null, + val formattedDayModel: EpoxyModel<*>? = null, + val numberOfMergedEvents: Int = 0 +) - override fun onVisibilityStateChanged(visibilityState: Int) { - if (visibilityState == VisibilityState.VISIBLE) { - callback?.onEventVisible(event) - } - } - - -} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/RoomMemberItemFactory.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/RoomMemberItemFactory.kt index 46c036ee..5d2efcba 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/RoomMemberItemFactory.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/RoomMemberItemFactory.kt @@ -34,6 +34,7 @@ class RoomMemberItemFactory(private val stringProvider: StringProvider) { val roomMember = event.roomMember ?: return null val noticeText = buildRoomMemberNotice(event) ?: return null return NoticeItem_() + .userId(event.root.sender ?: "") .noticeText(noticeText) .avatarUrl(roomMember.avatarUrl) .memberName(roomMember.displayName) @@ -57,9 +58,9 @@ class RoomMemberItemFactory(private val stringProvider: StringProvider) { val displayNameText = when { prevEventContent?.displayName.isNullOrEmpty() -> stringProvider.getString(R.string.notice_display_name_set, event.root.sender, eventContent?.displayName) - eventContent?.displayName.isNullOrEmpty() -> + eventContent?.displayName.isNullOrEmpty() -> stringProvider.getString(R.string.notice_display_name_removed, event.root.sender, prevEventContent?.displayName) - else -> + else -> stringProvider.getString(R.string.notice_display_name_changed_from, event.root.sender, prevEventContent?.displayName, eventContent?.displayName) } @@ -86,20 +87,20 @@ class RoomMemberItemFactory(private val stringProvider: StringProvider) { // TODO get userId val selfUserId: String = "" when { - eventContent.thirdPartyInvite != null -> + eventContent.thirdPartyInvite != null -> stringProvider.getString(R.string.notice_room_third_party_registered_invite, targetDisplayName, eventContent.thirdPartyInvite?.displayName) TextUtils.equals(event.root.stateKey, selfUserId) -> stringProvider.getString(R.string.notice_room_invite_you, senderDisplayName) - event.root.stateKey.isNullOrEmpty() -> + event.root.stateKey.isNullOrEmpty() -> stringProvider.getString(R.string.notice_room_invite_no_invitee, senderDisplayName) - else -> + else -> stringProvider.getString(R.string.notice_room_invite, senderDisplayName, targetDisplayName) } } - Membership.JOIN == eventContent?.membership -> + Membership.JOIN == eventContent?.membership -> stringProvider.getString(R.string.notice_room_join, senderDisplayName) - Membership.LEAVE == eventContent?.membership -> + Membership.LEAVE == eventContent?.membership -> // 2 cases here: this member may have left voluntarily or they may have been "left" by someone else ie. kicked return if (TextUtils.equals(event.root.sender, event.root.stateKey)) { if (prevEventContent?.membership == Membership.INVITE) { @@ -116,11 +117,11 @@ class RoomMemberItemFactory(private val stringProvider: StringProvider) { } else { null } - Membership.BAN == eventContent?.membership -> + Membership.BAN == eventContent?.membership -> stringProvider.getString(R.string.notice_room_ban, senderDisplayName, targetDisplayName) - Membership.KNOCK == eventContent?.membership -> + Membership.KNOCK == eventContent?.membership -> stringProvider.getString(R.string.notice_room_kick, senderDisplayName, targetDisplayName) - else -> null + else -> null } } diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 234490e4..f980cb60 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -16,11 +16,14 @@ package im.vector.riotredesign.features.home.room.detail.timeline.factory +import com.airbnb.epoxy.EpoxyModelWithHolder import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotredesign.core.epoxy.EmptyItem_ import im.vector.riotredesign.core.epoxy.VectorEpoxyModel import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController +import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener +import im.vector.riotredesign.features.home.room.detail.timeline.item.RoomMemberMergedItem class TimelineItemFactory(private val messageItemFactory: MessageItemFactory, private val roomNameItemFactory: RoomNameItemFactory, @@ -31,33 +34,57 @@ class TimelineItemFactory(private val messageItemFactory: MessageItemFactory, private val defaultItemFactory: DefaultItemFactory) { fun create(event: TimelineEvent, + mergeableEvents: List, nextEvent: TimelineEvent?, - callback: TimelineEventController.Callback?): VectorEpoxyModel<*> { + callback: TimelineEventController.Callback?, + visibilityStateChangedListener: TimelineEventVisibilityStateChangedListener): EpoxyModelWithHolder<*> { val computedModel = try { - when (event.root.type) { - EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, callback) - EventType.STATE_ROOM_NAME -> roomNameItemFactory.create(event) - EventType.STATE_ROOM_TOPIC -> roomTopicItemFactory.create(event) - EventType.STATE_ROOM_MEMBER -> roomMemberItemFactory.create(event) - EventType.STATE_HISTORY_VISIBILITY -> roomHistoryVisibilityItemFactory.create(event) + if (mergeableEvents.isNotEmpty()) { + createMergedEvent(event, mergeableEvents, visibilityStateChangedListener) + } else { + when (event.root.type) { + EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, callback) + EventType.STATE_ROOM_NAME -> roomNameItemFactory.create(event) + EventType.STATE_ROOM_TOPIC -> roomTopicItemFactory.create(event) + EventType.STATE_ROOM_MEMBER -> roomMemberItemFactory.create(event) + EventType.STATE_HISTORY_VISIBILITY -> roomHistoryVisibilityItemFactory.create(event) - EventType.CALL_INVITE, - EventType.CALL_HANGUP, - EventType.CALL_ANSWER -> callItemFactory.create(event) + EventType.CALL_INVITE, + EventType.CALL_HANGUP, + EventType.CALL_ANSWER -> callItemFactory.create(event) - EventType.ENCRYPTED, - EventType.ENCRYPTION, - EventType.STATE_ROOM_THIRD_PARTY_INVITE, - EventType.STICKER, - EventType.STATE_ROOM_CREATE -> defaultItemFactory.create(event) + EventType.ENCRYPTED, + EventType.ENCRYPTION, + EventType.STATE_ROOM_THIRD_PARTY_INVITE, + EventType.STICKER, + EventType.STATE_ROOM_CREATE -> defaultItemFactory.create(event) - else -> null + else -> null + } } } catch (e: Exception) { defaultItemFactory.create(event, e) } - return computedModel ?: EmptyItem_() + return (computedModel ?: EmptyItem_()).apply { + if (this is VectorEpoxyModel) { + this.setOnVisibilityStateChanged(visibilityStateChangedListener) + } + } + } + + private fun createMergedEvent(event: TimelineEvent, + mergeableEvents: List, + visibilityStateChangedListener: VectorEpoxyModel.OnVisibilityStateChangedListener): RoomMemberMergedItem { + + val events = listOf(event) + mergeableEvents + // We are reversing it as it does add items on a LinearLayout + val roomMemberItems = events.reversed().mapNotNull { + roomMemberItemFactory.create(it)?.apply { + id(it.localId) + } + } + return RoomMemberMergedItem(events, roomMemberItems, visibilityStateChangedListener) } } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index 565f4254..deb4ad44 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -48,8 +48,30 @@ fun List.filterDisplayableEvents(): List { } } +fun TimelineEvent.canBeMerged(): Boolean { + return root.type == EventType.STATE_ROOM_MEMBER +} + +fun List.nextSameTypeEvents(index: Int, minSize: Int): List { + if (index >= size - 1) { + return emptyList() + } + val timelineEvent = this[index] + val nextSubList = subList(index + 1, size) + val indexOfFirstDifferentEventType = nextSubList.indexOfFirst { it.root.type != timelineEvent.root.type } + val sameTypeEvents = if (indexOfFirstDifferentEventType == -1) { + nextSubList + } else { + nextSubList.subList(0, indexOfFirstDifferentEventType) + } + if (sameTypeEvents.size < minSize) { + return emptyList() + } + return sameTypeEvents +} + fun List.nextDisplayableEvent(index: Int): TimelineEvent? { - return if (index == size - 1) { + return if (index >= size - 1) { null } else { subList(index + 1, this.size).firstOrNull { it.isDisplayable() } diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt new file mode 100644 index 00000000..0b329ec8 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt @@ -0,0 +1,36 @@ +/* + * + * * 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.riotredesign.features.home.room.detail.timeline.helper + +import com.airbnb.epoxy.VisibilityState +import im.vector.matrix.android.api.session.room.timeline.TimelineEvent +import im.vector.riotredesign.core.epoxy.VectorEpoxyModel +import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController + +class TimelineEventVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?, + private val events: List) + : VectorEpoxyModel.OnVisibilityStateChangedListener { + + override fun onVisibilityStateChanged(visibilityState: Int) { + if (visibilityState == VisibilityState.VISIBLE) { + callback?.onEventsVisible(events) + } + } + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/RoomMemberMergedItem.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/RoomMemberMergedItem.kt new file mode 100644 index 00000000..11f21745 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/RoomMemberMergedItem.kt @@ -0,0 +1,99 @@ +/* + * + * * 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.riotredesign.features.home.room.detail.timeline.item + +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.core.view.children +import com.airbnb.epoxy.EpoxyModelGroup +import com.airbnb.epoxy.ModelGroupHolder +import im.vector.matrix.android.api.session.room.timeline.TimelineEvent +import im.vector.riotredesign.R +import im.vector.riotredesign.core.epoxy.VectorEpoxyModel +import im.vector.riotredesign.features.home.AvatarRenderer + +class RoomMemberMergedItem(val events: List, + private val roomMemberItems: List, + private val visibilityStateChangedListener: VectorEpoxyModel.OnVisibilityStateChangedListener +) : EpoxyModelGroup(R.layout.item_timeline_event_room_member_merged, roomMemberItems) { + + private val distinctRoomMemberItems = roomMemberItems.distinctBy { it.userId } + var isCollapsed = true + set(value) { + field = value + updateModelVisibility() + } + + init { + updateModelVisibility() + } + + override fun onVisibilityStateChanged(visibilityState: Int, view: ModelGroupHolder) { + super.onVisibilityStateChanged(visibilityState, view) + visibilityStateChangedListener.onVisibilityStateChanged(visibilityState) + } + + override fun bind(holder: ModelGroupHolder) { + super.bind(holder) + val expandView = holder.rootView.findViewById(R.id.itemMergedExpandTextView) + val summaryView = holder.rootView.findViewById(R.id.itemMergedSummaryTextView) + val separatorView = holder.rootView.findViewById(R.id.itemMergedSeparatorView) + val avatarListView = holder.rootView.findViewById(R.id.itemMergedAvatarListView) + if (isCollapsed) { + val summary = holder.rootView.resources.getQuantityString(R.plurals.membership_changes, roomMemberItems.size, roomMemberItems.size) + summaryView.text = summary + summaryView.visibility = View.VISIBLE + avatarListView.visibility = View.VISIBLE + avatarListView.children.forEachIndexed { index, view -> + val roomMemberItem = distinctRoomMemberItems.getOrNull(index) + if (roomMemberItem != null && view is ImageView) { + view.visibility = View.VISIBLE + AvatarRenderer.render(roomMemberItem.avatarUrl, roomMemberItem.userId, roomMemberItem.memberName?.toString(), view) + } else { + view.visibility = View.GONE + } + } + separatorView.visibility = View.GONE + expandView.setText(R.string.merged_events_expand) + } else { + avatarListView.visibility = View.INVISIBLE + summaryView.visibility = View.GONE + separatorView.visibility = View.VISIBLE + expandView.setText(R.string.merged_events_collapse) + } + expandView.setOnClickListener { _ -> + isCollapsed = !isCollapsed + updateModelVisibility() + bind(holder) + } + } + + private fun updateModelVisibility() { + roomMemberItems.forEach { + if (isCollapsed) { + it.hide() + } else { + it.show() + } + } + } + +} \ No newline at end of file diff --git a/vector/src/main/res/layout/item_timeline_event_room_member_merged.xml b/vector/src/main/res/layout/item_timeline_event_room_member_merged.xml new file mode 100644 index 00000000..2d33bcac --- /dev/null +++ b/vector/src/main/res/layout/item_timeline_event_room_member_merged.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/vector_message_merge_avatar_list.xml b/vector/src/main/res/layout/vector_message_merge_avatar_list.xml new file mode 100644 index 00000000..d12d8266 --- /dev/null +++ b/vector/src/main/res/layout/vector_message_merge_avatar_list.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + \ No newline at end of file