From d8f449388ca3e70bf3ba25e8486d7e04b12fb41e Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 20 Aug 2019 18:30:24 +0200 Subject: [PATCH 01/22] Read marker: start working on it (no UI) --- gradle.properties | 2 +- .../api/session/room/read/FullyReadContent.kt | 25 ++++++ .../api/session/room/read/ReadService.kt | 5 ++ .../session/room/timeline/TimelineEvent.kt | 3 +- .../database/helper/ChunkEntityHelper.kt | 3 +- .../database/mapper/RoomSummaryMapper.kt | 4 +- .../database/mapper/TimelineEventMapper.kt | 3 +- .../database/model/ReadMarkerEntity.kt | 35 +++++++++ .../database/model/SessionRealmModule.kt | 3 +- .../database/model/TimelineEventEntity.kt | 3 +- .../database/query/ReadMarkerEntityQueries.kt | 37 +++++++++ .../session/room/read/DefaultReadService.kt | 11 +++ .../session/room/read/SetReadMarkersTask.kt | 37 +++++---- .../session/room/timeline/DefaultTimeline.kt | 78 +++++++++++++------ .../session/sync/RoomFullyReadHandler.kt | 45 +++++++++++ .../internal/session/sync/RoomSyncHandler.kt | 29 +++++-- .../home/room/detail/RoomDetailViewModel.kt | 4 + .../detail/timeline/item/AbsMessageItem.kt | 2 + .../timeline/item/MessageInformationData.kt | 3 +- .../room/detail/timeline/item/NoticeItem.kt | 3 + .../util/MessageInformationDataFactory.kt | 5 +- .../res/layout/item_timeline_event_base.xml | 10 ++- .../item_timeline_event_base_noinfo.xml | 10 +++ 23 files changed, 304 insertions(+), 56 deletions(-) create mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/FullyReadContent.kt create mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ReadMarkerEntity.kt create mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadMarkerEntityQueries.kt create mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt diff --git a/gradle.properties b/gradle.properties index 2e2b110f15..35ca815df8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,7 +16,7 @@ org.gradle.jvmargs=-Xmx1536m vector.debugPrivateData=false -vector.httpLogLevel=NONE +vector.httpLogLevel=HEADERS # Note: to debug, you can put and uncomment the following lines in the file ~/.gradle/gradle.properties to override the value above #vector.debugPrivateData=true diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/FullyReadContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/FullyReadContent.kt new file mode 100644 index 0000000000..a73b9ef5b7 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/FullyReadContent.kt @@ -0,0 +1,25 @@ +/* + * 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.matrix.android.api.session.room.read + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class FullyReadContent( + @Json(name = "event_id") val eventId: String +) \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/ReadService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/ReadService.kt index d97fc497f0..0ff0298b44 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/ReadService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/ReadService.kt @@ -42,5 +42,10 @@ interface ReadService { fun isEventRead(eventId: String): Boolean + /** + * Returns a nullable read marker for the room. + */ + fun getReadMarkerLive(): LiveData + fun getEventReadReceiptsLive(eventId: String): LiveData> } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt index 36ca360e08..f250824b1f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineEvent.kt @@ -39,7 +39,8 @@ data class TimelineEvent( val isUniqueDisplayName: Boolean, val senderAvatar: String?, val annotations: EventAnnotationsSummary? = null, - val readReceipts: List = emptyList() + val readReceipts: List = emptyList(), + val hasReadMarker: Boolean = false ) { val metadata = HashMap() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt index 69065f5171..3824fed779 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/helper/ChunkEntityHelper.kt @@ -23,6 +23,7 @@ import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.mapper.toEntity import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity +import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntity import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity @@ -157,7 +158,6 @@ internal fun ChunkEntity.add(roomId: String, } } - val eventEntity = TimelineEventEntity(localId).also { it.root = event.toEntity(roomId).apply { this.stateIndex = currentStateIndex @@ -169,6 +169,7 @@ internal fun ChunkEntity.add(roomId: String, it.roomId = roomId it.annotations = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst() it.readReceipts = readReceiptsSummaryEntity + it.readMarker = ReadMarkerEntity.where(realm, roomId = roomId, eventId = eventId).findFirst() } val position = if (direction == PaginationDirection.FORWARDS) 0 else this.timelineEvents.size timelineEvents.add(position, eventEntity) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt index 95d4d8bc62..03061c6edd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt @@ -26,8 +26,8 @@ import java.util.* import javax.inject.Inject internal class RoomSummaryMapper @Inject constructor( - val cryptoService: CryptoService, - val timelineEventMapper: TimelineEventMapper + private val cryptoService: CryptoService, + private val timelineEventMapper: TimelineEventMapper ) { fun map(roomSummaryEntity: RoomSummaryEntity): RoomSummary { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt index fe98ebfb5b..0e9f13155e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt @@ -45,7 +45,8 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS senderAvatar = timelineEventEntity.senderAvatar, readReceipts = readReceipts?.sortedByDescending { it.originServerTs - } ?: emptyList() + } ?: emptyList(), + hasReadMarker = timelineEventEntity.readMarker?.eventId?.isEmpty() == false ) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ReadMarkerEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ReadMarkerEntity.kt new file mode 100644 index 0000000000..d67308b283 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ReadMarkerEntity.kt @@ -0,0 +1,35 @@ +/* + * 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.matrix.android.internal.database.model + +import io.realm.RealmObject +import io.realm.RealmResults +import io.realm.annotations.LinkingObjects +import io.realm.annotations.PrimaryKey + +internal open class ReadMarkerEntity( + @PrimaryKey + var roomId: String = "", + var eventId: String = "" +) : RealmObject() { + + @LinkingObjects("readMarker") + val timelineEvent: RealmResults? = null + + companion object + +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt index 1d27bf07ee..0aa6ac1dd6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt @@ -43,6 +43,7 @@ import io.realm.annotations.RealmModule PushConditionEntity::class, PusherEntity::class, PusherDataEntity::class, - ReadReceiptsSummaryEntity::class + ReadReceiptsSummaryEntity::class, + ReadMarkerEntity::class ]) internal class SessionRealmModule diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/TimelineEventEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/TimelineEventEntity.kt index 429b2291f6..e727ce40c7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/TimelineEventEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/TimelineEventEntity.kt @@ -31,7 +31,8 @@ internal open class TimelineEventEntity(var localId: Long = 0, var isUniqueDisplayName: Boolean = false, var senderAvatar: String? = null, var senderMembershipEvent: EventEntity? = null, - var readReceipts: ReadReceiptsSummaryEntity? = null + var readReceipts: ReadReceiptsSummaryEntity? = null, + var readMarker: ReadMarkerEntity? = null ) : RealmObject() { @LinkingObjects("timelineEvents") diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadMarkerEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadMarkerEntityQueries.kt new file mode 100644 index 0000000000..061634a9da --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadMarkerEntityQueries.kt @@ -0,0 +1,37 @@ +/* + * 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.matrix.android.internal.database.query + +import im.vector.matrix.android.internal.database.model.ReadMarkerEntity +import im.vector.matrix.android.internal.database.model.ReadMarkerEntityFields +import io.realm.Realm +import io.realm.RealmQuery +import io.realm.kotlin.where + +internal fun ReadMarkerEntity.Companion.where(realm: Realm, roomId: String, eventId: String? = null): RealmQuery { + val query = realm.where() + .equalTo(ReadMarkerEntityFields.ROOM_ID, roomId) + if (eventId != null) { + query.equalTo(ReadMarkerEntityFields.EVENT_ID, eventId) + } + return query +} + +internal fun ReadMarkerEntity.Companion.getOrCreate(realm: Realm, roomId: String): ReadMarkerEntity { + return where(realm, roomId).findFirst() + ?: realm.createObject(ReadMarkerEntity::class.java, roomId) +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt index 505b958911..3709521cc3 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt @@ -28,8 +28,10 @@ import im.vector.matrix.android.api.session.room.read.ReadService import im.vector.matrix.android.internal.database.RealmLiveData import im.vector.matrix.android.internal.database.mapper.ReadReceiptsSummaryMapper import im.vector.matrix.android.internal.database.model.ChunkEntity +import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntity import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity +import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.query.find import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.where @@ -93,6 +95,15 @@ internal class DefaultReadService @AssistedInject constructor(@Assisted private return isEventRead } + override fun getReadMarkerLive(): LiveData { + val liveRealmData = RealmLiveData(monarchy.realmConfiguration) { realm -> + ReadMarkerEntity.where(realm, roomId) + } + return Transformations.map(liveRealmData) { results -> + results.firstOrNull()?.eventId + } + } + override fun getEventReadReceiptsLive(eventId: String): LiveData> { val liveEntity = RealmLiveData(monarchy.realmConfiguration) { realm -> ReadReceiptsSummaryEntity.where(realm, eventId) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt index 41c9cca507..af05510c8a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt @@ -19,6 +19,7 @@ package im.vector.matrix.android.internal.session.room.read import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.internal.database.model.ChunkEntity +import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity @@ -57,6 +58,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI val fullyReadEventId: String? val readReceiptEventId: String? + Timber.v("Execute set read marker with params: $params") if (params.markAllAsRead) { val latestSyncedEventId = Realm.getInstance(monarchy.realmConfiguration).use { realm -> TimelineEventEntity.latestEvent(realm, roomId = params.roomId, includesSending = false)?.eventId @@ -68,7 +70,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI readReceiptEventId = params.readReceiptEventId } - if (fullyReadEventId != null) { + if (fullyReadEventId != null && isReadMarkerMoreRecent(params.roomId, fullyReadEventId)) { if (LocalEchoEventFactory.isLocalEchoId(fullyReadEventId)) { Timber.w("Can't set read marker for local event ${params.fullyReadEventId}") } else { @@ -76,7 +78,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI } } if (readReceiptEventId != null - && !isEventRead(params.roomId, readReceiptEventId)) { + && !isEventRead(params.roomId, readReceiptEventId)) { if (LocalEchoEventFactory.isLocalEchoId(readReceiptEventId)) { Timber.w("Can't set read receipt for local event ${params.fullyReadEventId}") @@ -93,12 +95,23 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI } } + private fun isReadMarkerMoreRecent(roomId: String, fullyReadEventId: String): Boolean { + return Realm.getInstance(monarchy.realmConfiguration).use { realm -> + val readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId).findFirst() + val readMarkerEvent = readMarkerEntity?.timelineEvent?.firstOrNull() + val eventToCheck = TimelineEventEntity.where(realm, eventId = fullyReadEventId).findFirst() + val readReceiptIndex = readMarkerEvent?.root?.displayIndex ?: Int.MAX_VALUE + val eventToCheckIndex = eventToCheck?.root?.displayIndex ?: Int.MIN_VALUE + eventToCheckIndex > readReceiptIndex + } + } + private fun updateNotificationCountIfNecessary(roomId: String, eventId: String) { monarchy.writeAsync { realm -> val isLatestReceived = TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId == eventId if (isLatestReceived) { val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst() - ?: return@writeAsync + ?: return@writeAsync roomSummary.notificationCount = 0 roomSummary.highlightCount = 0 } @@ -106,19 +119,17 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI } private fun isEventRead(roomId: String, eventId: String): Boolean { - var isEventRead = false - monarchy.doWithRealm { - val readReceipt = ReadReceiptEntity.where(it, roomId, credentials.userId).findFirst() - ?: return@doWithRealm - val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(it, roomId) - ?: return@doWithRealm + return Realm.getInstance(monarchy.realmConfiguration).use { realm -> + val readReceipt = ReadReceiptEntity.where(realm, roomId, credentials.userId).findFirst() + ?: return false + val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) + ?: return false val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex - ?: Int.MIN_VALUE + ?: Int.MIN_VALUE val eventToCheckIndex = liveChunk.timelineEvents.find(eventId)?.root?.displayIndex - ?: Int.MAX_VALUE - isEventRead = eventToCheckIndex <= readReceiptIndex + ?: Int.MAX_VALUE + eventToCheckIndex <= readReceiptIndex } - return isEventRead } } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index 03f5da6e6f..26983a82c6 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -32,6 +32,7 @@ import im.vector.matrix.android.internal.database.model.ChunkEntityFields import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.EventEntityFields +import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields @@ -47,10 +48,12 @@ import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.util.Debouncer import im.vector.matrix.android.internal.util.createBackgroundHandler import im.vector.matrix.android.internal.util.createUIHandler +import io.realm.ObjectChangeSet import io.realm.OrderedCollectionChangeSet import io.realm.OrderedRealmCollectionChangeListener import io.realm.Realm import io.realm.RealmConfiguration +import io.realm.RealmObjectChangeListener import io.realm.RealmQuery import io.realm.RealmResults import io.realm.Sort @@ -101,6 +104,7 @@ internal class DefaultTimeline( private lateinit var eventRelations: RealmResults private var roomEntity: RoomEntity? = null + private var readMarkerEntity: ReadMarkerEntity? = null private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN @@ -149,13 +153,9 @@ internal class DefaultTimeline( changeSet.changes.forEach { index -> val eventEntity = results[index] eventEntity?.eventId?.let { eventId -> - builtEventsIdMap[eventId]?.let { builtIndex -> - //Update an existing event - builtEvents[builtIndex]?.let { te -> - builtEvents[builtIndex] = buildTimelineEvent(eventEntity) - hasChanged = true - } - } + hasChanged = rebuildEvent(eventId) { + buildTimelineEvent(eventEntity) + } || hasChanged } } if (hasChanged) postSnapshot() @@ -163,27 +163,44 @@ internal class DefaultTimeline( } private val relationsListener = OrderedRealmCollectionChangeListener> { collection, changeSet -> - var hasChange = false (changeSet.insertions + changeSet.changes).forEach { val eventRelations = collection[it] if (eventRelations != null) { - builtEventsIdMap[eventRelations.eventId]?.let { builtIndex -> - //Update the relation of existing event - builtEvents[builtIndex]?.let { te -> - builtEvents[builtIndex] = te.copy(annotations = eventRelations.asDomain()) - hasChange = true + hasChange = rebuildEvent(eventRelations.eventId) { te -> + te.copy(annotations = eventRelations.asDomain()) + } || hasChange + } + } + if (hasChange) postSnapshot() + } + + private val readMarkerListener = RealmObjectChangeListener { readMarkerEntity: ReadMarkerEntity, _: ObjectChangeSet? -> + val isEventHidden = liveEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, readMarkerEntity.eventId).findFirst() == null + var hasChange = false + if (isEventHidden) { + val hiddenEvent = readMarkerEntity.timelineEvent?.firstOrNull() ?: return@RealmObjectChangeListener + val displayIndex = hiddenEvent.root?.displayIndex + if (displayIndex != null) { + // Then we are looking for the first displayable event after the hidden one + val firstDisplayedEvent = liveEvents.where() + .lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex) + .findFirst() + + // If we find one, we should rebuild this one with marker + if (firstDisplayedEvent != null) { + hasChange = rebuildEvent(firstDisplayedEvent.eventId) { + it.copy(hasReadMarker = true) } } } } - if (hasChange) - postSnapshot() + if (hasChange) postSnapshot() } - // Public methods ****************************************************************************** +// Public methods ****************************************************************************** override fun paginate(direction: Timeline.Direction, count: Int) { BACKGROUND_HANDLER.post { @@ -237,6 +254,10 @@ internal class DefaultTimeline( .findAllAsync() .also { it.addChangeListener(relationsListener) } + readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId) + .findFirstAsync() + .also { it.addChangeListener(readMarkerListener) } + if (settings.buildReadReceipts) { hiddenReadReceipts.start(realm, liveEvents, this) } @@ -255,6 +276,7 @@ internal class DefaultTimeline( roomEntity?.sendingTimelineEvents?.removeAllChangeListeners() eventRelations.removeAllChangeListeners() liveEvents.removeAllChangeListeners() + readMarkerEntity?.removeAllChangeListeners() if (settings.buildReadReceipts) { hiddenReadReceipts.dispose() } @@ -272,20 +294,26 @@ internal class DefaultTimeline( // TimelineHiddenReadReceipts.Delegate override fun rebuildEvent(eventId: String, readReceipts: List): Boolean { - return builtEventsIdMap[eventId]?.let { builtIndex -> - //Update the relation of existing event - builtEvents[builtIndex]?.let { te -> - builtEvents[builtIndex] = te.copy(readReceipts = readReceipts) - true - } - } ?: false + return rebuildEvent(eventId) { te -> + te.copy(readReceipts = readReceipts) + } } override fun onReadReceiptsUpdated() { postSnapshot() } -// Private methods ***************************************************************************** + // Private methods ***************************************************************************** + + private fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent): Boolean { + return builtEventsIdMap[eventId]?.let { builtIndex -> + //Update the relation of existing event + builtEvents[builtIndex]?.let { te -> + builtEvents[builtIndex] = builder(te) + true + } + } ?: false + } private fun hasMoreInCache(direction: Timeline.Direction): Boolean { return Realm.getInstance(realmConfiguration).use { localRealm -> @@ -571,7 +599,7 @@ internal class DefaultTimeline( debouncer.debounce("post_snapshot", runnable, 50) } - // Extension methods *************************************************************************** +// Extension methods *************************************************************************** private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt new file mode 100644 index 0000000000..f142ca069e --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt @@ -0,0 +1,45 @@ +/* + * 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.matrix.android.internal.session.sync + +import im.vector.matrix.android.api.session.room.read.FullyReadContent +import im.vector.matrix.android.internal.database.model.ReadMarkerEntity +import im.vector.matrix.android.internal.database.model.TimelineEventEntity +import im.vector.matrix.android.internal.database.query.getOrCreate +import im.vector.matrix.android.internal.database.query.where +import io.realm.Realm +import timber.log.Timber +import javax.inject.Inject + +internal class RoomFullyReadHandler @Inject constructor() { + + fun handle(realm: Realm, roomId: String, content: FullyReadContent?) { + if (content == null) { + return + } + Timber.v("Handle for roomId: $roomId eventId: ${content.eventId}") + val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId).apply { + eventId = content.eventId + } + // Remove the old marker if any + readMarkerEntity.timelineEvent?.firstOrNull()?.readMarker = null + // Attach to timelineEvent if known + val timelineEventEntity = TimelineEventEntity.where(realm, eventId = content.eventId).findFirst() + timelineEventEntity?.readMarker = readMarkerEntity + } + +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt index 74b56e774c..0b4897e089 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt @@ -23,8 +23,13 @@ import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.tag.RoomTagContent +import im.vector.matrix.android.api.session.room.read.FullyReadContent import im.vector.matrix.android.internal.crypto.CryptoManager -import im.vector.matrix.android.internal.database.helper.* +import im.vector.matrix.android.internal.database.helper.add +import im.vector.matrix.android.internal.database.helper.addOrUpdate +import im.vector.matrix.android.internal.database.helper.addStateEvent +import im.vector.matrix.android.internal.database.helper.lastStateIndex +import im.vector.matrix.android.internal.database.helper.updateSenderDataFor import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.EventEntityFields import im.vector.matrix.android.internal.database.model.RoomEntity @@ -37,7 +42,11 @@ import im.vector.matrix.android.internal.session.notification.DefaultPushRuleSer import im.vector.matrix.android.internal.session.notification.ProcessEventForPushTask import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection -import im.vector.matrix.android.internal.session.sync.model.* +import im.vector.matrix.android.internal.session.sync.model.InvitedRoomSync +import im.vector.matrix.android.internal.session.sync.model.RoomSync +import im.vector.matrix.android.internal.session.sync.model.RoomSyncAccountData +import im.vector.matrix.android.internal.session.sync.model.RoomSyncEphemeral +import im.vector.matrix.android.internal.session.sync.model.RoomsSyncResponse import im.vector.matrix.android.internal.session.user.UserEntityFactory import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.configureWith @@ -50,6 +59,7 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch private val readReceiptHandler: ReadReceiptHandler, private val roomSummaryUpdater: RoomSummaryUpdater, private val roomTagHandler: RoomTagHandler, + private val roomFullyReadHandler: RoomFullyReadHandler, private val cryptoManager: CryptoManager, private val tokenStore: SyncTokenStore, private val pushRuleService: DefaultPushRuleService, @@ -247,11 +257,16 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch } private fun handleRoomAccountDataEvents(realm: Realm, roomId: String, accountData: RoomSyncAccountData) { - accountData.events - .asSequence() - .filter { it.getClearType() == EventType.TAG } - .map { it.content.toModel() } - .forEach { roomTagHandler.handle(realm, roomId, it) } + for (event in accountData.events) { + val eventType = event.getClearType() + if (eventType == EventType.TAG) { + val content = event.getClearContent().toModel() + roomTagHandler.handle(realm, roomId, content) + } else if (eventType == EventType.FULLY_READ) { + val content = event.getClearContent().toModel() + roomFullyReadHandler.handle(realm, roomId, content) + } + } } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index 1cd8cc4a41..607f999e30 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -626,9 +626,13 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro .buffer(1, TimeUnit.SECONDS) .filter { it.isNotEmpty() } .subscribeBy(onNext = { actions -> + val readMarkerVisible = actions.find { it.event.hasReadMarker } != null val mostRecentEvent = actions.maxBy { it.event.displayIndex } mostRecentEvent?.event?.root?.eventId?.let { eventId -> room.setReadReceipt(eventId, callback = object : MatrixCallback {}) + if (readMarkerVisible) { + room.setReadMarker(eventId, callback = object : MatrixCallback {}) + } } }) .disposeOnClear() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt index a394f47124..570daf669c 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -129,6 +129,7 @@ abstract class AbsMessageItem : BaseEventItem() { holder.memberNameView.setOnLongClickListener(null) } + holder.readMarkerView.isVisible = informationData.displayReadMarker holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener) if (!shouldShowReactionAtBottom() || informationData.orderedReactionList.isNullOrEmpty()) { @@ -182,6 +183,7 @@ abstract class AbsMessageItem : BaseEventItem() { val memberNameView by bind(R.id.messageMemberNameView) val timeView by bind(R.id.messageTimeView) val readReceiptsView by bind(R.id.readReceiptsView) + val readMarkerView by bind(R.id.readMarkerView) var reactionWrapper: ViewGroup? = null var reactionFlowHelper: Flow? = null } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt index d46b2a8db3..041b6dbddd 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt @@ -33,7 +33,8 @@ data class MessageInformationData( val orderedReactionList: List? = null, val hasBeenEdited: Boolean = false, val hasPendingEdits: Boolean = false, - val readReceipts: List = emptyList() + val readReceipts: List = emptyList(), + val displayReadMarker: Boolean = false ) : Parcelable diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt index 51a7b0ce38..dd42dc7b66 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt @@ -19,6 +19,7 @@ package im.vector.riotx.features.home.room.detail.timeline.item import android.view.View import android.widget.ImageView import android.widget.TextView +import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.riotx.R @@ -65,6 +66,7 @@ abstract class NoticeItem : BaseEventItem() { ) holder.view.setOnLongClickListener(longClickListener) holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener) + holder.readMarkerView.isVisible = informationData.displayReadMarker } override fun getViewType() = STUB_ID @@ -73,6 +75,7 @@ abstract class NoticeItem : BaseEventItem() { val avatarImageView by bind(R.id.itemNoticeAvatarView) val noticeTextView by bind(R.id.itemNoticeTextView) val readReceiptsView by bind(R.id.readReceiptsView) + val readMarkerView by bind(R.id.readMarkerView) } companion object { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/util/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/util/MessageInformationDataFactory.kt index a00dd3fa9f..71a7549b46 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/util/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/util/MessageInformationDataFactory.kt @@ -62,6 +62,8 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId ?: "")) } + val displayReadMarker = event.hasReadMarker && event.readReceipts.find { it.user.userId == session.myUserId } == null + return MessageInformationData( eventId = eventId, senderId = event.root.senderId ?: "", @@ -85,7 +87,8 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses .map { ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs) } - .toList() + .toList(), + displayReadMarker = displayReadMarker ) } } \ No newline at end of file diff --git a/vector/src/main/res/layout/item_timeline_event_base.xml b/vector/src/main/res/layout/item_timeline_event_base.xml index 2f0be78f38..ea4cfd5d4a 100644 --- a/vector/src/main/res/layout/item_timeline_event_base.xml +++ b/vector/src/main/res/layout/item_timeline_event_base.xml @@ -122,7 +122,6 @@ - + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_timeline_event_base_noinfo.xml b/vector/src/main/res/layout/item_timeline_event_base_noinfo.xml index 7726839902..ad6999c5ee 100644 --- a/vector/src/main/res/layout/item_timeline_event_base_noinfo.xml +++ b/vector/src/main/res/layout/item_timeline_event_base_noinfo.xml @@ -61,5 +61,15 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" /> + + \ No newline at end of file From 51a4c936761b5f5194832859bd3e4f9e74a4c72c Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 20 Aug 2019 19:12:22 +0200 Subject: [PATCH 02/22] Read markers: continue working on ui --- .../api/session/room/model/RoomSummary.kt | 9 +- .../api/session/room/timeline/Timeline.kt | 18 +- .../database/mapper/RoomSummaryMapper.kt | 15 +- .../database/model/RoomSummaryEntity.kt | 3 +- .../query/ReadReceiptEntityQueries.kt | 7 + .../query/RoomSummaryEntityQueries.kt | 6 + .../matrix/android/internal/di/MatrixScope.kt | 2 +- .../android/internal/session/SessionModule.kt | 2 - .../android/internal/session/SessionScope.kt | 2 +- .../session/room/read/SetReadMarkersTask.kt | 23 +- .../session/room/timeline/DefaultTimeline.kt | 137 ++++++----- .../room/timeline/DefaultTimelineService.kt | 3 +- .../room/timeline/TimelineHiddenReadMarker.kt | 96 ++++++++ .../session/sync/RoomFullyReadHandler.kt | 6 + .../core/ui/views/JumpToReadMarkerView.kt | 75 ++++++ .../riotx/core/ui/views/ReadMarkerView.kt | 86 +++++++ .../home/room/detail/DownloadFileState.kt | 25 ++ .../home/room/detail/RoomDetailActions.kt | 11 +- .../home/room/detail/RoomDetailFragment.kt | 112 +++++++-- .../home/room/detail/RoomDetailViewModel.kt | 178 +++++++------- .../home/room/detail/RoomDetailViewState.kt | 4 +- .../ScrollOnHighlightedEventCallback.kt | 2 +- .../timeline/TimelineEventController.kt | 69 ++++-- .../timeline/factory/EncryptedItemFactory.kt | 22 +- .../timeline/factory/EncryptionItemFactory.kt | 71 ------ .../timeline/factory/MessageItemFactory.kt | 223 +++++------------- .../timeline/factory/NoticeItemFactory.kt | 5 +- .../timeline/factory/TimelineItemFactory.kt | 11 +- .../timeline/format/NoticeEventFormatter.kt | 14 +- .../MessageInformationDataFactory.kt | 33 +-- .../helper/MessageItemAttributesFactory.kt | 58 +++++ .../helper/TimelineDisplayableEvents.kt | 27 +-- ...lineEventVisibilityStateChangedListener.kt | 9 +- .../detail/timeline/item/AbsMessageItem.kt | 108 ++++----- .../detail/timeline/item/BaseEventItem.kt | 3 + .../timeline/item/MessageImageVideoItem.kt | 12 +- .../detail/timeline/item/MessageTextItem.kt | 4 +- .../room/detail/timeline/item/NoticeItem.kt | 19 +- .../src/main/res/anim/unread_marker_anim.xml | 2 - .../main/res/layout/fragment_room_detail.xml | 82 ++++--- .../res/layout/item_timeline_event_base.xml | 17 +- .../item_timeline_event_base_noinfo.xml | 17 +- ...item_timeline_event_merged_header_stub.xml | 2 +- .../res/layout/view_jump_to_read_marker.xml | 41 ++++ .../src/main/res/layout/view_read_marker.xml | 58 +++++ 45 files changed, 1073 insertions(+), 656 deletions(-) create mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt create mode 100644 vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt create mode 100644 vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/home/room/detail/DownloadFileState.kt delete mode 100644 vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt rename vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/{util => helper}/MessageInformationDataFactory.kt (82%) create mode 100644 vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt create mode 100644 vector/src/main/res/layout/view_jump_to_read_marker.xml create mode 100644 vector/src/main/res/layout/view_read_marker.xml diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt index aae72dd41f..36aab8db29 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt @@ -35,9 +35,14 @@ data class RoomSummary( val highlightCount: Int = 0, val tags: List = emptyList(), val membership: Membership = Membership.NONE, - val versioningState: VersioningState = VersioningState.NONE + val versioningState: VersioningState = VersioningState.NONE, + val readMarkerId: String? = null ) { val isVersioned: Boolean get() = versioningState != VersioningState.NONE -} \ No newline at end of file + + val hasNewMessages: Boolean + get() = notificationCount != 0 +} + diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt index 314c9f61b8..3f90d3cd13 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt @@ -32,6 +32,8 @@ interface Timeline { var listener: Listener? + val isLive: Boolean + /** * This should be called before any other method after creating the timeline. It ensures the underlying database is open */ @@ -42,6 +44,10 @@ interface Timeline { */ fun dispose() + + fun restartWithEventId(eventId: String) + + /** * Check if the timeline can be enriched by paginating. * @param the direction to check in @@ -49,6 +55,7 @@ interface Timeline { */ fun hasMoreToLoad(direction: Direction): Boolean + /** * This is the main method to enrich the timeline with new data. * It will call the onUpdated method from [Listener] when the data will be processed. @@ -56,9 +63,16 @@ interface Timeline { */ fun paginate(direction: Direction, count: Int) - fun pendingEventCount() : Int + fun pendingEventCount(): Int + + fun failedToDeliverEventCount(): Int + + fun getIndexOfEvent(eventId: String?): Int? + + fun getTimelineEventAtIndex(index: Int): TimelineEvent? + + fun getTimelineEventWithId(eventId: String?): TimelineEvent? - fun failedToDeliverEventCount() : Int interface Listener { /** diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt index 03061c6edd..cf829b44bc 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/RoomSummaryMapper.kt @@ -43,12 +43,12 @@ internal class RoomSummaryMapper @Inject constructor( //for now decrypt sync try { val result = cryptoService.decryptEvent(latestEvent.root, latestEvent.root.roomId + UUID.randomUUID().toString()) - latestEvent.root.mxDecryptionResult = OlmDecryptionResult( - payload = result.clearEvent, - senderKey = result.senderCurve25519Key, - keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) }, - forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain - ) + latestEvent.root.mxDecryptionResult = OlmDecryptionResult( + payload = result.clearEvent, + senderKey = result.senderCurve25519Key, + keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) }, + forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain + ) } catch (e: MXCryptoError) { } @@ -65,7 +65,8 @@ internal class RoomSummaryMapper @Inject constructor( notificationCount = roomSummaryEntity.notificationCount, tags = tags, membership = roomSummaryEntity.membership, - versioningState = roomSummaryEntity.versioningState + versioningState = roomSummaryEntity.versioningState, + readMarkerId = roomSummaryEntity.readMarkerId ) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt index 6fe81f4cdd..dde01c3740 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt @@ -35,7 +35,8 @@ internal open class RoomSummaryEntity(@PrimaryKey var roomId: String = "", var otherMemberIds: RealmList = RealmList(), var notificationCount: Int = 0, var highlightCount: Int = 0, - var tags: RealmList = RealmList() + var tags: RealmList = RealmList(), + var readMarkerId: String? = null ) : RealmObject() { private var membershipStr: String = Membership.NONE.name diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadReceiptEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadReceiptEntityQueries.kt index acac419946..330d76fd15 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadReceiptEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadReceiptEntityQueries.kt @@ -20,6 +20,7 @@ import im.vector.matrix.android.internal.database.model.ReadReceiptEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntityFields import io.realm.Realm import io.realm.RealmQuery +import io.realm.RealmResults import io.realm.kotlin.where internal fun ReadReceiptEntity.Companion.where(realm: Realm, roomId: String, userId: String): RealmQuery { @@ -28,6 +29,12 @@ internal fun ReadReceiptEntity.Companion.where(realm: Realm, roomId: String, use .equalTo(ReadReceiptEntityFields.USER_ID, userId) } +internal fun ReadReceiptEntity.Companion.whereUserId(realm: Realm, userId: String): RealmQuery { + return realm.where() + .equalTo(ReadReceiptEntityFields.USER_ID, userId) +} + + internal fun ReadReceiptEntity.Companion.createUnmanaged(roomId: String, eventId: String, userId: String, originServerTs: Double): ReadReceiptEntity { return ReadReceiptEntity().apply { this.primaryKey = "${roomId}_$userId" diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/RoomSummaryEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/RoomSummaryEntityQueries.kt index f2c260421f..bfa3f2c51c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/RoomSummaryEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/RoomSummaryEntityQueries.kt @@ -31,6 +31,12 @@ internal fun RoomSummaryEntity.Companion.where(realm: Realm, roomId: String? = n return query } +internal fun RoomSummaryEntity.Companion.getOrCreate(realm: Realm, roomId: String): RoomSummaryEntity { + return where(realm, roomId).findFirst() + ?: realm.createObject(RoomSummaryEntity::class.java, roomId) +} + + internal fun RoomSummaryEntity.Companion.getDirectRooms(realm: Realm): RealmResults { return RoomSummaryEntity.where(realm) .equalTo(RoomSummaryEntityFields.IS_DIRECT, true) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixScope.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixScope.kt index 9c9327df55..032b645f59 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixScope.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixScope.kt @@ -21,4 +21,4 @@ import javax.inject.Scope @Scope @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) -annotation class MatrixScope \ No newline at end of file +internal annotation class MatrixScope \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt index ab44a4aa93..106a80ce9f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt @@ -36,9 +36,7 @@ import im.vector.matrix.android.internal.di.Unauthenticated import im.vector.matrix.android.internal.network.AccessTokenInterceptor import im.vector.matrix.android.internal.network.RetrofitFactory import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater -import im.vector.matrix.android.internal.session.room.DefaultRoomFactory import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater -import im.vector.matrix.android.internal.session.room.RoomFactory import im.vector.matrix.android.internal.session.room.create.RoomCreateEventLiveObserver import im.vector.matrix.android.internal.session.room.prune.EventsPruner import im.vector.matrix.android.internal.session.room.tombstone.RoomTombstoneEventLiveObserver diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionScope.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionScope.kt index 37753fdfcc..964165e00d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionScope.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionScope.kt @@ -21,4 +21,4 @@ import javax.inject.Scope @Scope @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) -annotation class SessionScope \ No newline at end of file +internal annotation class SessionScope \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt index af05510c8a..9652faae81 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt @@ -18,6 +18,7 @@ package im.vector.matrix.android.internal.session.room.read import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.auth.data.Credentials +import im.vector.matrix.android.api.session.room.read.FullyReadContent import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntity @@ -25,13 +26,17 @@ import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.query.find import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom +import im.vector.matrix.android.internal.database.query.getOrCreate import im.vector.matrix.android.internal.database.query.latestEvent import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.network.executeRequest import im.vector.matrix.android.internal.session.room.RoomAPI import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory +import im.vector.matrix.android.internal.session.sync.RoomFullyReadHandler import im.vector.matrix.android.internal.task.Task +import im.vector.matrix.android.internal.util.awaitTransaction import io.realm.Realm +import io.realm.RealmConfiguration import timber.log.Timber import javax.inject.Inject @@ -50,7 +55,8 @@ private const val READ_RECEIPT = "m.read" internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI: RoomAPI, private val credentials: Credentials, - private val monarchy: Monarchy + private val monarchy: Monarchy, + private val roomFullyReadHandler: RoomFullyReadHandler ) : SetReadMarkersTask { override suspend fun execute(params: SetReadMarkersTask.Params) { @@ -74,12 +80,12 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI if (LocalEchoEventFactory.isLocalEchoId(fullyReadEventId)) { Timber.w("Can't set read marker for local event ${params.fullyReadEventId}") } else { + updateReadMarker(params.roomId, fullyReadEventId) markers[READ_MARKER] = fullyReadEventId } } if (readReceiptEventId != null && !isEventRead(params.roomId, readReceiptEventId)) { - if (LocalEchoEventFactory.isLocalEchoId(readReceiptEventId)) { Timber.w("Can't set read receipt for local event ${params.fullyReadEventId}") } else { @@ -95,6 +101,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI } } + private fun isReadMarkerMoreRecent(roomId: String, fullyReadEventId: String): Boolean { return Realm.getInstance(monarchy.realmConfiguration).use { realm -> val readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId).findFirst() @@ -106,12 +113,18 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI } } - private fun updateNotificationCountIfNecessary(roomId: String, eventId: String) { - monarchy.writeAsync { realm -> + private suspend fun updateReadMarker(roomId: String, eventId: String) { + monarchy.awaitTransaction { realm -> + roomFullyReadHandler.handle(realm, roomId, FullyReadContent(eventId)) + } + } + + private suspend fun updateNotificationCountIfNecessary(roomId: String, eventId: String) { + monarchy.awaitTransaction { realm -> val isLatestReceived = TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId == eventId if (isLatestReceived) { val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst() - ?: return@writeAsync + ?: return@awaitTransaction roomSummary.notificationCount = 0 roomSummary.highlightCount = 0 } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index 26983a82c6..f14df5ada2 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -27,36 +27,15 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineSettings import im.vector.matrix.android.api.util.CancelableBag import im.vector.matrix.android.internal.database.mapper.TimelineEventMapper import im.vector.matrix.android.internal.database.mapper.asDomain -import im.vector.matrix.android.internal.database.model.ChunkEntity -import im.vector.matrix.android.internal.database.model.ChunkEntityFields -import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity -import im.vector.matrix.android.internal.database.model.EventEntity -import im.vector.matrix.android.internal.database.model.EventEntityFields -import im.vector.matrix.android.internal.database.model.ReadMarkerEntity -import im.vector.matrix.android.internal.database.model.RoomEntity -import im.vector.matrix.android.internal.database.model.TimelineEventEntity -import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields -import im.vector.matrix.android.internal.database.query.FilterContent -import im.vector.matrix.android.internal.database.query.findAllInRoomWithSendStates -import im.vector.matrix.android.internal.database.query.findIncludingEvent -import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom -import im.vector.matrix.android.internal.database.query.where -import im.vector.matrix.android.internal.database.query.whereInRoom +import im.vector.matrix.android.internal.database.model.* +import im.vector.matrix.android.internal.database.query.* import im.vector.matrix.android.internal.task.TaskConstraints import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.util.Debouncer import im.vector.matrix.android.internal.util.createBackgroundHandler import im.vector.matrix.android.internal.util.createUIHandler -import io.realm.ObjectChangeSet -import io.realm.OrderedCollectionChangeSet -import io.realm.OrderedRealmCollectionChangeListener -import io.realm.Realm -import io.realm.RealmConfiguration -import io.realm.RealmObjectChangeListener -import io.realm.RealmQuery -import io.realm.RealmResults -import io.realm.Sort +import io.realm.* import timber.log.Timber import java.util.* import java.util.concurrent.atomic.AtomicBoolean @@ -70,7 +49,7 @@ private const val DISPLAY_INDEX_UNKNOWN = Int.MIN_VALUE internal class DefaultTimeline( private val roomId: String, - private val initialEventId: String? = null, + private var initialEventId: String? = null, private val realmConfiguration: RealmConfiguration, private val taskExecutor: TaskExecutor, private val contextOfEventTask: GetContextOfEventTask, @@ -78,8 +57,9 @@ internal class DefaultTimeline( private val cryptoService: CryptoService, private val timelineEventMapper: TimelineEventMapper, private val settings: TimelineSettings, - private val hiddenReadReceipts: TimelineHiddenReadReceipts -) : Timeline, TimelineHiddenReadReceipts.Delegate { + private val hiddenReadReceipts: TimelineHiddenReadReceipts, + private val hiddenReadMarker: TimelineHiddenReadMarker +) : Timeline, TimelineHiddenReadReceipts.Delegate, TimelineHiddenReadMarker.Delegate { private companion object { val BACKGROUND_HANDLER = createBackgroundHandler("TIMELINE_DB_THREAD") @@ -104,11 +84,9 @@ internal class DefaultTimeline( private lateinit var eventRelations: RealmResults private var roomEntity: RoomEntity? = null - private var readMarkerEntity: ReadMarkerEntity? = null private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN - private val isLive = initialEventId == null private val builtEvents = Collections.synchronizedList(ArrayList()) private val builtEventsIdMap = Collections.synchronizedMap(HashMap()) private val backwardsPaginationState = AtomicReference(PaginationState()) @@ -116,6 +94,9 @@ internal class DefaultTimeline( private val timelineID = UUID.randomUUID().toString() + override val isLive + get() = initialEventId == null + private val eventDecryptor = TimelineEventDecryptor(realmConfiguration, timelineID, cryptoService) private val eventsChangeListener = OrderedRealmCollectionChangeListener> { results, changeSet -> @@ -124,10 +105,7 @@ internal class DefaultTimeline( } else { // If changeSet has deletion we are having a gap, so we clear everything if (changeSet.deletionRanges.isNotEmpty()) { - prevDisplayIndex = DISPLAY_INDEX_UNKNOWN - nextDisplayIndex = DISPLAY_INDEX_UNKNOWN - builtEvents.clear() - builtEventsIdMap.clear() + clearAllValues() } changeSet.insertionRanges.forEach { range -> val (startDisplayIndex, direction) = if (range.startIndex == 0) { @@ -176,29 +154,6 @@ internal class DefaultTimeline( if (hasChange) postSnapshot() } - private val readMarkerListener = RealmObjectChangeListener { readMarkerEntity: ReadMarkerEntity, _: ObjectChangeSet? -> - val isEventHidden = liveEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, readMarkerEntity.eventId).findFirst() == null - var hasChange = false - if (isEventHidden) { - val hiddenEvent = readMarkerEntity.timelineEvent?.firstOrNull() ?: return@RealmObjectChangeListener - val displayIndex = hiddenEvent.root?.displayIndex - if (displayIndex != null) { - // Then we are looking for the first displayable event after the hidden one - val firstDisplayedEvent = liveEvents.where() - .lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex) - .findFirst() - - // If we find one, we should rebuild this one with marker - if (firstDisplayedEvent != null) { - hasChange = rebuildEvent(firstDisplayedEvent.eventId) { - it.copy(hasReadMarker = true) - } - } - } - } - if (hasChange) postSnapshot() - } - // Public methods ****************************************************************************** @@ -254,14 +209,10 @@ internal class DefaultTimeline( .findAllAsync() .also { it.addChangeListener(relationsListener) } - readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId) - .findFirstAsync() - .also { it.addChangeListener(readMarkerListener) } - if (settings.buildReadReceipts) { hiddenReadReceipts.start(realm, liveEvents, this) } - + hiddenReadMarker.start(realm, liveEvents, this) isReady.set(true) } } @@ -276,10 +227,11 @@ internal class DefaultTimeline( roomEntity?.sendingTimelineEvents?.removeAllChangeListeners() eventRelations.removeAllChangeListeners() liveEvents.removeAllChangeListeners() - readMarkerEntity?.removeAllChangeListeners() + hiddenReadMarker.dispose() if (settings.buildReadReceipts) { hiddenReadReceipts.dispose() } + clearAllValues() backgroundRealm.getAndSet(null).also { it.close() } @@ -287,6 +239,27 @@ internal class DefaultTimeline( } } + override fun restartWithEventId(eventId: String) { + dispose() + initialEventId = eventId + start() + postSnapshot() + } + + override fun getIndexOfEvent(eventId: String?): Int? { + return builtEventsIdMap[eventId] + } + + override fun getTimelineEventAtIndex(index: Int): TimelineEvent? { + return builtEvents.getOrNull(index) + } + + override fun getTimelineEventWithId(eventId: String?): TimelineEvent? { + return builtEventsIdMap[eventId]?.let { + getTimelineEventAtIndex(it) + } + } + override fun hasMoreToLoad(direction: Timeline.Direction): Boolean { return hasMoreInCache(direction) || !hasReachedEnd(direction) } @@ -303,6 +276,18 @@ internal class DefaultTimeline( postSnapshot() } + // TimelineHiddenReadMarker.Delegate + + override fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean { + return rebuildEvent(eventId) { te -> + te.copy(hasReadMarker = hasReadMarker) + } + } + + override fun onReadMarkerUpdated() { + postSnapshot() + } + // Private methods ***************************************************************************** private fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent): Boolean { @@ -423,8 +408,9 @@ internal class DefaultTimeline( prevDisplayIndex = initialDisplayIndex nextDisplayIndex = initialDisplayIndex - if (initialEventId != null && shouldFetchInitialEvent) { - fetchEvent(initialEventId) + val currentInitialEventId = initialEventId + if (currentInitialEventId != null && shouldFetchInitialEvent) { + fetchEvent(currentInitialEventId) } else { val count = Math.min(settings.initialSize, liveEvents.size) if (isLive) { @@ -571,10 +557,11 @@ internal class DefaultTimeline( } private fun findCurrentChunk(realm: Realm): ChunkEntity? { - return if (initialEventId == null) { + val currentInitialEventId = initialEventId + return if (currentInitialEventId == null) { ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) } else { - ChunkEntity.findIncludingEvent(realm, initialEventId) + ChunkEntity.findIncludingEvent(realm, currentInitialEventId) } } @@ -594,11 +581,23 @@ internal class DefaultTimeline( } private fun postSnapshot() { - val snapshot = createSnapshot() - val runnable = Runnable { listener?.onUpdated(snapshot) } - debouncer.debounce("post_snapshot", runnable, 50) + BACKGROUND_HANDLER.post { + val snapshot = createSnapshot() + val runnable = Runnable { listener?.onUpdated(snapshot) } + debouncer.debounce("post_snapshot", runnable, 50) + } } + private fun clearAllValues() { + prevDisplayIndex = DISPLAY_INDEX_UNKNOWN + nextDisplayIndex = DISPLAY_INDEX_UNKNOWN + builtEvents.clear() + builtEventsIdMap.clear() + backwardsPaginationState.set(PaginationState()) + forwardsPaginationState.set(PaginationState()) + } + + // Extension methods *************************************************************************** private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt index b6cc80ca78..59d37a8062 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt @@ -59,7 +59,8 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv cryptoService, timelineEventMapper, settings, - TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings) + TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings), + TimelineHiddenReadMarker(roomId) ) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt new file mode 100644 index 0000000000..532a66140e --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt @@ -0,0 +1,96 @@ +/* + + * 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.matrix.android.internal.session.room.timeline + +import im.vector.matrix.android.internal.database.model.ReadMarkerEntity +import im.vector.matrix.android.internal.database.model.TimelineEventEntity +import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields +import im.vector.matrix.android.internal.database.query.where +import io.realm.Realm +import io.realm.RealmObjectChangeListener +import io.realm.RealmResults + +/** + * This class is responsible for handling the read marker for hidden events. + * When an hidden event has read marker, we want to transfer it on the first older displayed event. + * It has to be used in [DefaultTimeline] and we should call the [start] and [dispose] methods to properly handle realm subscription. + */ +internal class TimelineHiddenReadMarker constructor(private val roomId: String) { + + interface Delegate { + fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean + fun onReadMarkerUpdated() + } + + private var previousDisplayedEventId: String? = null + private var readMarkerEntity: ReadMarkerEntity? = null + + private lateinit var liveEvents: RealmResults + private lateinit var delegate: Delegate + + private val readMarkerListener = RealmObjectChangeListener { readMarker, _ -> + var hasChange = false + previousDisplayedEventId?.also { + hasChange = delegate.rebuildEvent(it, false) + previousDisplayedEventId = null + } + val isEventHidden = liveEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, readMarker.eventId).findFirst() == null + if (isEventHidden) { + val hiddenEvent = readMarker.timelineEvent?.firstOrNull() + ?: return@RealmObjectChangeListener + val displayIndex = hiddenEvent.root?.displayIndex + if (displayIndex != null) { + // Then we are looking for the first displayable event after the hidden one + val firstDisplayedEvent = liveEvents.where() + .lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex) + .findFirst() + + // If we find one, we should rebuild this one with marker + if (firstDisplayedEvent != null) { + previousDisplayedEventId = firstDisplayedEvent.eventId + hasChange = delegate.rebuildEvent(firstDisplayedEvent.eventId, true) + } + } + } + if (hasChange) delegate.onReadMarkerUpdated() + } + + + /** + * Start the realm query subscription. Has to be called on an HandlerThread + */ + fun start(realm: Realm, liveEvents: RealmResults, delegate: Delegate) { + this.liveEvents = liveEvents + this.delegate = delegate + // We are looking for read receipts set on hidden events. + // We only accept those with a timelineEvent (so coming from pagination/sync). + readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId) + .findFirstAsync() + .also { it.addChangeListener(readMarkerListener) } + + } + + /** + * Dispose the realm query subscription. Has to be called on an HandlerThread + */ + fun dispose() { + this.readMarkerEntity?.removeAllChangeListeners() + } + +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt index f142ca069e..9757d0f421 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt @@ -18,6 +18,7 @@ package im.vector.matrix.android.internal.session.sync import im.vector.matrix.android.api.session.room.read.FullyReadContent import im.vector.matrix.android.internal.database.model.ReadMarkerEntity +import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.query.getOrCreate import im.vector.matrix.android.internal.database.query.where @@ -32,9 +33,14 @@ internal class RoomFullyReadHandler @Inject constructor() { return } Timber.v("Handle for roomId: $roomId eventId: ${content.eventId}") + + RoomSummaryEntity.getOrCreate(realm, roomId).apply { + readMarkerId = content.eventId + } val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId).apply { eventId = content.eventId } + // Remove the old marker if any readMarkerEntity.timelineEvent?.firstOrNull()?.readMarker = null // Attach to timelineEvent if known diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt new file mode 100644 index 0000000000..398d525217 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt @@ -0,0 +1,75 @@ +/* + + * 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.core.ui.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import android.widget.RelativeLayout +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import butterknife.ButterKnife +import im.vector.riotx.R +import im.vector.riotx.features.themes.ThemeUtils +import kotlinx.android.synthetic.main.view_jump_to_read_marker.view.* +import me.gujun.android.span.span +import me.saket.bettermovementmethod.BetterLinkMovementMethod + +class JumpToReadMarkerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : RelativeLayout(context, attrs, defStyleAttr) { + + interface Callback { + fun onJumpToReadMarkerClicked(readMarkerId: String) + fun onClearReadMarkerClicked() + } + + var callback: Callback? = null + + init { + setupView() + } + + private fun setupView() { + LinearLayout.inflate(context, R.layout.view_jump_to_read_marker, this) + setBackgroundColor(ContextCompat.getColor(context, R.color.notification_accent_color)) + jumpToReadMarkerLabelView.movementMethod = BetterLinkMovementMethod.getInstance() + isClickable = true + closeJumpToReadMarkerView.setOnClickListener { + visibility = View.GONE + callback?.onClearReadMarkerClicked() + } + } + + fun render(show: Boolean, readMarkerId: String?) { + isVisible = show + if (readMarkerId != null) { + jumpToReadMarkerLabelView.text = span(resources.getString(R.string.room_jump_to_first_unread)) { + textDecorationLine = "underline" + onClick = { callback?.onJumpToReadMarkerClicked(readMarkerId) } + } + } + + } + + +} diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt new file mode 100644 index 0000000000..becab54da3 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt @@ -0,0 +1,86 @@ +/* + + * 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.core.ui.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import im.vector.riotx.R +import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData +import kotlinx.coroutines.* + +private const val DELAY_IN_MS = 1_500L + +class ReadMarkerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + interface Callback { + fun onReadMarkerDisplayed() + } + + private var callback: Callback? = null + private var callbackDispatcherJob: Job? = null + + fun bindView(informationData: MessageInformationData, readMarkerCallback: Callback) { + this.callback = readMarkerCallback + if (informationData.displayReadMarker) { + visibility = VISIBLE + callbackDispatcherJob = GlobalScope.launch(Dispatchers.Main) { + delay(DELAY_IN_MS) + callback?.onReadMarkerDisplayed() + } + startAnimation() + } else { + visibility = INVISIBLE + } + + } + + fun unbind() { + this.callbackDispatcherJob?.cancel() + this.callback = null + this.animation?.cancel() + this.visibility = INVISIBLE + } + + private fun startAnimation() { + if (animation == null) { + animation = AnimationUtils.loadAnimation(context, R.anim.unread_marker_anim) + animation.startOffset = DELAY_IN_MS / 2 + animation.duration = DELAY_IN_MS / 2 + animation.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation) { + } + + override fun onAnimationEnd(animation: Animation) { + visibility = INVISIBLE + } + + override fun onAnimationRepeat(animation: Animation) {} + }) + } + animation.start() + } + +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/DownloadFileState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/DownloadFileState.kt new file mode 100644 index 0000000000..2426a41e75 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/DownloadFileState.kt @@ -0,0 +1,25 @@ +/* + * 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 + +import java.io.File + +data class DownloadFileState( + val mimeType: String, + val file: File?, + val throwable: Throwable? + ) \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt index e60bc422a8..70d0d59c06 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt @@ -18,7 +18,6 @@ package im.vector.riotx.features.home.room.detail import com.jaiselrahman.filepicker.model.MediaFile import im.vector.matrix.android.api.session.events.model.Event -import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary import im.vector.matrix.android.api.session.room.model.message.MessageFileContent import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent @@ -27,15 +26,18 @@ sealed class RoomDetailActions { data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailActions() data class SendMedia(val mediaFiles: List) : RoomDetailActions() - data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions() + data class TimelineEventTurnsVisible(val event: TimelineEvent) : RoomDetailActions() + data class TimelineEventTurnsInvisible(val event: TimelineEvent) : RoomDetailActions() data class LoadMoreTimelineEvents(val direction: Timeline.Direction) : RoomDetailActions() data class SendReaction(val reaction: String, val targetEventId: String) : RoomDetailActions() data class RedactAction(val targetEventId: String, val reason: String? = "") : RoomDetailActions() data class UndoReaction(val targetEventId: String, val key: String, val reason: String? = "") : RoomDetailActions() data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailActions() - data class NavigateToEvent(val eventId: String, val position: Int?) : RoomDetailActions() + data class NavigateToEvent(val eventId: String, val highlight: Boolean) : RoomDetailActions() + data class SetReadMarkerAction(val eventId: String) : RoomDetailActions() + object MarkAllAsRead : RoomDetailActions() data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailActions() - data class HandleTombstoneEvent(val event: Event): RoomDetailActions() + data class HandleTombstoneEvent(val event: Event) : RoomDetailActions() object AcceptInvite : RoomDetailActions() object RejectInvite : RoomDetailActions() @@ -47,5 +49,4 @@ sealed class RoomDetailActions { object ClearSendQueue : RoomDetailActions() object ResendAll : RoomDetailActions() - } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 19262fad49..fd83a6f69e 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -28,7 +28,12 @@ import android.os.Parcelable import android.text.Editable import android.text.Spannable import android.text.TextUtils -import android.view.* +import android.view.HapticFeedbackConstants +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.Window import android.view.inputmethod.InputMethodManager import android.widget.TextView import android.widget.Toast @@ -46,7 +51,12 @@ import androidx.recyclerview.widget.RecyclerView import butterknife.BindView import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.EpoxyVisibilityTracker -import com.airbnb.mvrx.* +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.args +import com.airbnb.mvrx.fragmentViewModel import com.github.piasy.biv.BigImageViewer import com.github.piasy.biv.loader.ImageLoader import com.google.android.material.snackbar.Snackbar @@ -60,7 +70,13 @@ import im.vector.matrix.android.api.permalinks.PermalinkFactory import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.room.model.Membership -import im.vector.matrix.android.api.session.room.model.message.* +import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent +import im.vector.matrix.android.api.session.room.model.message.MessageContent +import im.vector.matrix.android.api.session.room.model.message.MessageFileContent +import im.vector.matrix.android.api.session.room.model.message.MessageImageContent +import im.vector.matrix.android.api.session.room.model.message.MessageTextContent +import im.vector.matrix.android.api.session.room.model.message.MessageType +import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent @@ -77,9 +93,21 @@ import im.vector.riotx.core.extensions.observeEvent import im.vector.riotx.core.extensions.setTextOrHide import im.vector.riotx.core.files.addEntryToDownloadManager import im.vector.riotx.core.glide.GlideApp -import im.vector.riotx.core.ui.views.NotificationAreaView import im.vector.riotx.core.platform.VectorBaseFragment -import im.vector.riotx.core.utils.* +import im.vector.riotx.core.ui.views.JumpToReadMarkerView +import im.vector.riotx.core.ui.views.NotificationAreaView +import im.vector.riotx.core.utils.PERMISSIONS_FOR_TAKING_PHOTO +import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES +import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_DOWNLOAD_FILE +import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA +import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA +import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA +import im.vector.riotx.core.utils.allGranted +import im.vector.riotx.core.utils.checkPermissions +import im.vector.riotx.core.utils.copyToClipboard +import im.vector.riotx.core.utils.openCamera +import im.vector.riotx.core.utils.shareMedia +import im.vector.riotx.core.utils.toast import im.vector.riotx.features.autocomplete.command.AutocompleteCommandPresenter import im.vector.riotx.features.autocomplete.command.CommandAutocompletePolicy import im.vector.riotx.features.autocomplete.user.AutocompleteUserPresenter @@ -94,9 +122,18 @@ import im.vector.riotx.features.home.room.detail.composer.TextComposerViewModel import im.vector.riotx.features.home.room.detail.composer.TextComposerViewState import im.vector.riotx.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController -import im.vector.riotx.features.home.room.detail.timeline.action.* +import im.vector.riotx.features.home.room.detail.timeline.action.ActionsHandler +import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsBottomSheet +import im.vector.riotx.features.home.room.detail.timeline.action.SimpleAction +import im.vector.riotx.features.home.room.detail.timeline.action.ViewEditHistoryBottomSheet +import im.vector.riotx.features.home.room.detail.timeline.action.ViewReactionBottomSheet import im.vector.riotx.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener -import im.vector.riotx.features.home.room.detail.timeline.item.* +import im.vector.riotx.features.home.room.detail.timeline.item.AbsMessageItem +import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem +import im.vector.riotx.features.home.room.detail.timeline.item.MessageImageVideoItem +import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData +import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem +import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.html.PillImageSpan import im.vector.riotx.features.invite.VectorInviteView @@ -134,7 +171,8 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callback, AutocompleteUserPresenter.Callback, - VectorInviteView.Callback { + VectorInviteView.Callback, + JumpToReadMarkerView.Callback { companion object { @@ -194,6 +232,7 @@ class RoomDetailFragment : override fun getMenuRes() = R.menu.menu_timeline private lateinit var actionViewModel: ActionsHandler + private lateinit var layoutManager: LinearLayoutManager @BindView(R.id.composerLayout) lateinit var composerLayout: TextComposerView @@ -211,6 +250,7 @@ class RoomDetailFragment : setupAttachmentButton() setupInviteView() setupNotificationView() + setupJumpToReadMarkerView() roomDetailViewModel.subscribe { renderState(it) } textComposerViewModel.subscribe { renderTextComposerState(it) } roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) } @@ -224,8 +264,12 @@ class RoomDetailFragment : } roomDetailViewModel.navigateToEvent.observeEvent(this) { - // - scrollOnHighlightedEventCallback.scheduleScrollTo(it) + val scrollPosition = timelineEventController.searchPositionOfEvent(it) + if (scrollPosition == null) { + scrollOnHighlightedEventCallback.scheduleScrollTo(it) + } else { + layoutManager.scrollToPosition(scrollPosition) + } } roomDetailViewModel.selectSubscribe(this, RoomDetailViewState::tombstoneEventHandling, uniqueOnly("tombstoneEventHandling")) { @@ -259,6 +303,10 @@ class RoomDetailFragment : } } + private fun setupJumpToReadMarkerView() { + jumpToReadMarkerView.callback = this + } + private fun setupNotificationView() { notificationAreaView.delegate = object : NotificationAreaView.Delegate { @@ -380,7 +428,7 @@ class RoomDetailFragment : private fun setupRecyclerView() { val epoxyVisibilityTracker = EpoxyVisibilityTracker() epoxyVisibilityTracker.attach(recyclerView) - val layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true) + layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true) val stateRestorer = LayoutManagerStateRestorer(layoutManager).register() scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager) scrollOnHighlightedEventCallback = ScrollOnHighlightedEventCallback(layoutManager, timelineEventController) @@ -405,7 +453,7 @@ class RoomDetailFragment : R.drawable.ic_reply, object : RoomMessageTouchHelperCallback.QuickReplayHandler { override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { - (model as? AbsMessageItem)?.informationData?.let { + (model as? AbsMessageItem)?.attributes?.informationData?.let { val eventId = it.eventId roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId)) } @@ -416,7 +464,7 @@ class RoomDetailFragment : is MessageFileItem, is MessageImageVideoItem, is MessageTextItem -> { - return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED + return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED } else -> false } @@ -585,7 +633,7 @@ class RoomDetailFragment : val summary = state.asyncRoomSummary() val inviter = state.asyncInviter() if (summary?.membership == Membership.JOIN) { - timelineEventController.setTimeline(state.timeline, state.eventId) + timelineEventController.setTimeline(state.timeline, state.highlightedEventId) inviteView.visibility = View.GONE val uid = session.myUserId val meMember = session.getRoom(state.roomId)?.getRoomMember(uid) @@ -608,10 +656,12 @@ class RoomDetailFragment : composerLayout.visibility = View.GONE notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent)) } + jumpToReadMarkerView.render(state.showJumpToReadMarker, summary?.readMarkerId) } private fun renderRoomSummary(state: RoomDetailViewState) { state.asyncRoomSummary()?.let { + if (it.membership.isLeft()) { Timber.w("The room has been left") activity?.finish() @@ -684,7 +734,7 @@ class RoomDetailFragment : .show() } -// TimelineEventController.Callback ************************************************************ + // TimelineEventController.Callback ************************************************************ override fun onUrlClicked(url: String): Boolean { return permalinkHandler.launch(requireActivity(), url, object : NavigateToRoomInterceptor { @@ -696,7 +746,7 @@ class RoomDetailFragment : showSnackWithMessage(getString(R.string.navigate_to_room_when_already_in_the_room)) } else { // Highlight and scroll to this event - roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(eventId, timelineEventController.searchPositionOfEvent(eventId))) + roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(eventId, true)) } return true } @@ -716,7 +766,11 @@ class RoomDetailFragment : } override fun onEventVisible(event: TimelineEvent) { - roomDetailViewModel.process(RoomDetailActions.EventDisplayed(event)) + roomDetailViewModel.process(RoomDetailActions.TimelineEventTurnsVisible(event)) + } + + override fun onEventInvisible(event: TimelineEvent) { + roomDetailViewModel.process(RoomDetailActions.TimelineEventTurnsInvisible(event)) } override fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) { @@ -836,7 +890,15 @@ class RoomDetailFragment : .show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS") } -// AutocompleteUserPresenter.Callback + override fun onReadMarkerLongDisplayed(informationData: MessageInformationData) { + val firstVisibleItem = layoutManager.findFirstVisibleItemPosition() + val eventId = timelineEventController.searchEventIdAtPosition(firstVisibleItem) + if (eventId != null) { + roomDetailViewModel.process(RoomDetailActions.SetReadMarkerAction(eventId)) + } + } + + // AutocompleteUserPresenter.Callback override fun onQueryUsers(query: CharSequence?) { textComposerViewModel.process(TextComposerActions.QueryUsers(query)) @@ -1001,7 +1063,7 @@ class RoomDetailFragment : snack.show() } -// VectorInviteView.Callback + // VectorInviteView.Callback override fun onAcceptInvite() { notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) @@ -1012,4 +1074,16 @@ class RoomDetailFragment : notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) roomDetailViewModel.process(RoomDetailActions.RejectInvite) } + + // JumpToReadMarkerView.Callback + + override fun onJumpToReadMarkerClicked(readMarkerId: String) { + roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(readMarkerId, false)) + } + + override fun onClearReadMarkerClicked() { + roomDetailViewModel.process(RoomDetailActions.MarkAllAsRead) + } + + } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index 607f999e30..bbdb7ab619 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -38,6 +38,7 @@ import im.vector.matrix.android.api.session.events.model.isTextMessage import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.file.FileService import im.vector.matrix.android.api.session.room.model.Membership +import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.getFileUrl @@ -58,6 +59,8 @@ import im.vector.riotx.features.command.CommandParser import im.vector.riotx.features.command.ParsedCommand import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDisplayableEvents import im.vector.riotx.features.settings.VectorPreferences +import io.reactivex.Observable +import io.reactivex.functions.Function3 import io.reactivex.rxkotlin.subscribeBy import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer @@ -75,7 +78,8 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro private val room = session.getRoom(initialState.roomId)!! private val roomId = initialState.roomId private val eventId = initialState.eventId - private val displayedEventsObservable = BehaviorRelay.create() + private val invisibleEventsObservable = BehaviorRelay.create() + private val visibleEventsObservable = BehaviorRelay.create() private val timelineSettings = if (userPreferencesProvider.shouldShowHiddenEvents()) { TimelineSettings(30, false, true, TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES, userPreferencesProvider.shouldShowReadReceipts()) } else { @@ -109,6 +113,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro observeRoomSummary() observeEventDisplayedActions() observeSummaryState() + observeJumpToReadMarkerViewVisibility() room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear() timeline.start() setState { copy(timeline = this@RoomDetailViewModel.timeline) } @@ -116,30 +121,37 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro fun process(action: RoomDetailActions) { when (action) { - is RoomDetailActions.SendMessage -> handleSendMessage(action) - is RoomDetailActions.SendMedia -> handleSendMedia(action) - is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action) - is RoomDetailActions.LoadMoreTimelineEvents -> handleLoadMore(action) - is RoomDetailActions.SendReaction -> handleSendReaction(action) - is RoomDetailActions.AcceptInvite -> handleAcceptInvite() - is RoomDetailActions.RejectInvite -> handleRejectInvite() - is RoomDetailActions.RedactAction -> handleRedactEvent(action) - is RoomDetailActions.UndoReaction -> handleUndoReact(action) - is RoomDetailActions.UpdateQuickReactAction -> handleUpdateQuickReaction(action) - is RoomDetailActions.EnterEditMode -> handleEditAction(action) - is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action) - is RoomDetailActions.EnterReplyMode -> handleReplyAction(action) - is RoomDetailActions.DownloadFile -> handleDownloadFile(action) - is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action) - is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(action) - is RoomDetailActions.ResendMessage -> handleResendEvent(action) - is RoomDetailActions.RemoveFailedEcho -> handleRemove(action) - is RoomDetailActions.ClearSendQueue -> handleClearSendQueue() - is RoomDetailActions.ResendAll -> handleResendAll() - else -> Timber.e("Unhandled Action: $action") + is RoomDetailActions.SendMessage -> handleSendMessage(action) + is RoomDetailActions.SendMedia -> handleSendMedia(action) + is RoomDetailActions.TimelineEventTurnsVisible -> handleEventVisible(action) + is RoomDetailActions.TimelineEventTurnsInvisible -> handleEventInvisible(action) + is RoomDetailActions.LoadMoreTimelineEvents -> handleLoadMore(action) + is RoomDetailActions.SendReaction -> handleSendReaction(action) + is RoomDetailActions.AcceptInvite -> handleAcceptInvite() + is RoomDetailActions.RejectInvite -> handleRejectInvite() + is RoomDetailActions.RedactAction -> handleRedactEvent(action) + is RoomDetailActions.UndoReaction -> handleUndoReact(action) + is RoomDetailActions.UpdateQuickReactAction -> handleUpdateQuickReaction(action) + is RoomDetailActions.EnterEditMode -> handleEditAction(action) + is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action) + is RoomDetailActions.EnterReplyMode -> handleReplyAction(action) + is RoomDetailActions.DownloadFile -> handleDownloadFile(action) + is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action) + is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(action) + is RoomDetailActions.ResendMessage -> handleResendEvent(action) + is RoomDetailActions.RemoveFailedEcho -> handleRemove(action) + is RoomDetailActions.ClearSendQueue -> handleClearSendQueue() + is RoomDetailActions.ResendAll -> handleResendAll() + is RoomDetailActions.SetReadMarkerAction -> handleSetReadMarkerAction(action) + is RoomDetailActions.MarkAllAsRead -> handleMarkAllAsRead() + else -> Timber.e("Unhandled Action: $action") } } + private fun handleEventInvisible(action: RoomDetailActions.TimelineEventTurnsInvisible) { + invisibleEventsObservable.accept(action) + } + private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) { val tombstoneContent = action.event.getClearContent().toModel() ?: return @@ -444,14 +456,14 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro room.sendMedias(attachments) } - private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) { + private fun handleEventVisible(action: RoomDetailActions.TimelineEventTurnsVisible) { if (action.event.root.sendState.isSent()) { //ignore pending/local events - displayedEventsObservable.accept(action) + visibleEventsObservable.accept(action) } //We need to update this with the related m.replace also (to move read receipt) action.event.annotations?.editSummary?.sourceEvents?.forEach { room.getTimeLineEvent(it)?.let { event -> - displayedEventsObservable.accept(RoomDetailActions.EventDisplayed(event)) + visibleEventsObservable.accept(RoomDetailActions.TimelineEventTurnsVisible(event)) } } } @@ -494,11 +506,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } } - data class DownloadFileState( - val mimeType: String, - val file: File?, - val throwable: Throwable? - ) private fun handleDownloadFile(action: RoomDetailActions.DownloadFile) { session.downloadFile( @@ -530,53 +537,15 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro private fun handleNavigateToEvent(action: RoomDetailActions.NavigateToEvent) { val targetEventId = action.eventId - - if (action.position != null) { - // Event is already in RAM - withState { - if (it.eventId == targetEventId) { - // ensure another click on the same permalink will also do a scroll - setState { - copy( - eventId = null - ) - } - } - - setState { - copy( - eventId = targetEventId - ) - } - } - - _navigateToEvent.postLiveEvent(targetEventId) - } else { - // change timeline - timeline.dispose() - timeline = room.createTimeline(targetEventId, timelineSettings) - timeline.start() - - withState { - if (it.eventId == targetEventId) { - // ensure another click on the same permalink will also do a scroll - setState { - copy( - eventId = null - ) - } - } - - setState { - copy( - eventId = targetEventId, - timeline = this@RoomDetailViewModel.timeline - ) - } - } - - _navigateToEvent.postLiveEvent(targetEventId) + val indexOfEvent = timeline.getIndexOfEvent(targetEventId) + if (indexOfEvent == null) { + // Event is not already in RAM + timeline.restartWithEventId(targetEventId) } + if (action.highlight) { + setState { copy(highlightedEventId = targetEventId) } + } + _navigateToEvent.postLiveEvent(targetEventId) } private fun handleResendEvent(action: RoomDetailActions.ResendMessage) { @@ -622,22 +591,36 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro private fun observeEventDisplayedActions() { // We are buffering scroll events for one second // and keep the most recent one to set the read receipt on. - displayedEventsObservable + visibleEventsObservable .buffer(1, TimeUnit.SECONDS) .filter { it.isNotEmpty() } .subscribeBy(onNext = { actions -> - val readMarkerVisible = actions.find { it.event.hasReadMarker } != null val mostRecentEvent = actions.maxBy { it.event.displayIndex } mostRecentEvent?.event?.root?.eventId?.let { eventId -> room.setReadReceipt(eventId, callback = object : MatrixCallback {}) - if (readMarkerVisible) { - room.setReadMarker(eventId, callback = object : MatrixCallback {}) - } } }) .disposeOnClear() } + private fun handleSetReadMarkerAction(action: RoomDetailActions.SetReadMarkerAction) = withState { state -> + var readMarkerId = action.eventId + if (readMarkerId == state.asyncRoomSummary()?.readMarkerId) { + val indexOfEvent = timeline.getIndexOfEvent(action.eventId) + // force to set the read marker on the next event + if (indexOfEvent != null) { + timeline.getTimelineEventAtIndex(indexOfEvent - 1)?.root?.eventId?.also { eventIdOfNext -> + readMarkerId = eventIdOfNext + } + } + } + room.setReadMarker(readMarkerId, callback = object : MatrixCallback {}) + } + + private fun handleMarkAllAsRead() { + room.markAllAsRead(object : MatrixCallback {}) + } + private fun observeSyncState() { session.rx() .liveSyncState() @@ -649,6 +632,39 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro .disposeOnClear() } + private fun observeJumpToReadMarkerViewVisibility() { + Observable + .combineLatest( + room.rx().liveRoomSummary(), + visibleEventsObservable.distinctUntilChanged(), + isEventVisibleObservable { it.hasReadMarker }.startWith(false), + Function3 { roomSummary, currentVisibleEvent, isReadMarkerViewVisible -> + val readMarkerId = roomSummary.readMarkerId + if (readMarkerId == null || isReadMarkerViewVisible || !timeline.isLive) { + false + } else { + val readMarkerPosition = timeline.getIndexOfEvent(readMarkerId) + ?: Int.MAX_VALUE + val currentVisibleEventPosition = timeline.getIndexOfEvent(currentVisibleEvent.event.root.eventId) + ?: Int.MIN_VALUE + readMarkerPosition > currentVisibleEventPosition + } + } + ) + .distinctUntilChanged() + .subscribe { + setState { copy(showJumpToReadMarker = it) } + } + .disposeOnClear() + } + + private fun isEventVisibleObservable(filterEvent: (TimelineEvent) -> Boolean): Observable { + return Observable.merge( + visibleEventsObservable.filter { filterEvent(it.event) }.map { true }, + invisibleEventsObservable.filter { filterEvent(it.event) }.map { false } + ) + } + private fun observeRoomSummary() { room.rx().liveRoomSummary() .execute { async -> diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt index d8358efe16..5e36cf42dc 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt @@ -51,7 +51,9 @@ data class RoomDetailViewState( val isEncrypted: Boolean = false, val tombstoneEvent: Event? = null, val tombstoneEventHandling: Async = Uninitialized, - val syncState: SyncState = SyncState.IDLE + val syncState: SyncState = SyncState.IDLE, + val showJumpToReadMarker: Boolean = false, + val highlightedEventId: String? = null ) : MvRxState { constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt index 43828b0ee2..cf483090f1 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt @@ -38,7 +38,7 @@ class ScrollOnHighlightedEventCallback(private val layoutManager: LinearLayoutMa // Do not scroll it item is already visible if (positionToScroll !in firstVisibleItem..lastVisibleItem) { // Note: Offset will be from the bottom, since the layoutManager is reversed - layoutManager.scrollToPositionWithOffset(positionToScroll, 120) + layoutManager.scrollToPosition(position) } scheduledEventId.set(null) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index 3c212d6129..ffc573a634 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -49,11 +49,11 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec private val timelineMediaSizeProvider: TimelineMediaSizeProvider, private val avatarRenderer: AvatarRenderer, @TimelineEventControllerHandler - private val backgroundHandler: Handler, - userPreferencesProvider: UserPreferencesProvider + private val backgroundHandler: Handler ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener { interface Callback : BaseCallback, ReactionPillCallback, AvatarCallback, UrlClickCallback, ReadReceiptsCallback { + fun onEventInvisible(event: TimelineEvent) fun onEventVisible(event: TimelineEvent) fun onRoomCreateLinkClicked(url: String) fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) @@ -81,6 +81,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec interface ReadReceiptsCallback { fun onReadReceiptsClicked(readReceipts: List) + fun onReadMarkerLongDisplayed(informationData: MessageInformationData) } interface UrlClickCallback { @@ -140,8 +141,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } - private val showHiddenEvents = userPreferencesProvider.shouldShowHiddenEvents() - init { requestModelBuild() } @@ -247,7 +246,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec private fun buildItemModels(currentPosition: Int, items: List): CacheItemData { val event = items[currentPosition] - val nextEvent = items.nextDisplayableEvent(currentPosition, showHiddenEvents) + val nextEvent = items.nextOrNull(currentPosition) val date = event.root.localDateTime() val nextDate = nextEvent?.root?.localDateTime() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() @@ -327,24 +326,50 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec return shouldAdd } - fun searchPositionOfEvent(eventId: String): Int? { - synchronized(modelCache) { - // Search in the cache - modelCache.forEachIndexed { idx, cacheItemData -> - if (cacheItemData?.eventId == eventId) { - return idx - } + fun searchPositionOfEvent(eventId: String): Int? = synchronized(modelCache) { + // Search in the cache + var realPosition = 0 + for (i in 0 until modelCache.size) { + val itemCache = modelCache[i] + if (itemCache?.eventId == eventId) { + return realPosition + } + if (itemCache?.eventModel != null) { + realPosition++ + } + if (itemCache?.mergedHeaderModel != null) { + realPosition++ + } + if (itemCache?.formattedDayModel != null) { + realPosition++ } - - return null } + return null } -} -private data class CacheItemData( - val localId: Long, - val eventId: String?, - val eventModel: EpoxyModel<*>? = null, - val mergedHeaderModel: MergedHeaderItem? = null, - val formattedDayModel: DaySeparatorItem? = null -) + fun searchEventIdAtPosition(position: Int): String? = synchronized(modelCache) { + var offsetValue = 0 + for (i in 0 until position) { + val itemCache = modelCache[i] + if (itemCache?.eventModel == null) { + offsetValue-- + } + if (itemCache?.mergedHeaderModel != null) { + offsetValue++ + } + if (itemCache?.formattedDayModel != null) { + offsetValue++ + } + } + return modelCache.getOrNull(position - offsetValue)?.eventId + } + + private data class CacheItemData( + val localId: Long, + val eventId: String?, + val eventModel: EpoxyModel<*>? = null, + val mergedHeaderModel: MergedHeaderItem? = null, + val formattedDayModel: DaySeparatorItem? = null + ) + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt index 080565cd16..938ac4673e 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt @@ -16,7 +16,6 @@ package im.vector.riotx.features.home.room.detail.timeline.factory -import android.view.View import im.vector.matrix.android.api.session.crypto.MXCryptoError import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.room.timeline.TimelineEvent @@ -24,11 +23,11 @@ import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.StringProvider -import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem_ -import im.vector.riotx.features.home.room.detail.timeline.util.MessageInformationDataFactory +import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory +import im.vector.riotx.features.home.room.detail.timeline.helper.MessageItemAttributesFactory import me.gujun.android.span.span import javax.inject.Inject @@ -36,7 +35,7 @@ import javax.inject.Inject class EncryptedItemFactory @Inject constructor(private val messageInformationDataFactory: MessageInformationDataFactory, private val colorProvider: ColorProvider, private val stringProvider: StringProvider, - private val avatarRenderer: AvatarRenderer) { + private val attributesFactory: MessageItemAttributesFactory) { fun create(event: TimelineEvent, nextEvent: TimelineEvent?, @@ -65,22 +64,13 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat // TODO This is not correct format for error, change it val informationData = messageInformationDataFactory.create(event, nextEvent) + val attributes = attributesFactory.create(null, informationData, callback) return MessageTextItem_() + .attributes(attributes) .message(spannableStr) - .avatarRenderer(avatarRenderer) - .colorProvider(colorProvider) - .informationData(informationData) .highlighted(highlight) - .avatarCallback(callback) .urlClickCallback(callback) - .cellClickListener( - DebouncedClickListener(View.OnClickListener { view -> - callback?.onEncryptedMessageClicked(informationData, view) - })) - .longClickListener { view -> - return@longClickListener callback?.onEventLongClicked(informationData, null, view) - ?: false - } + } else -> null } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt deleted file mode 100644 index 4a3f50c45e..0000000000 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptionItemFactory.kt +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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.factory - -import im.vector.matrix.android.api.session.events.model.Event -import im.vector.matrix.android.api.session.events.model.EventType -import im.vector.matrix.android.api.session.events.model.toModel -import im.vector.matrix.android.api.session.room.timeline.TimelineEvent -import im.vector.matrix.android.internal.crypto.model.event.EncryptionEventContent -import im.vector.riotx.R -import im.vector.riotx.core.resources.StringProvider -import im.vector.riotx.features.home.AvatarRenderer -import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController -import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar -import im.vector.riotx.features.home.room.detail.timeline.helper.senderName -import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData -import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem -import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_ -import javax.inject.Inject - -class EncryptionItemFactory @Inject constructor(private val stringProvider: StringProvider, - private val avatarRenderer: AvatarRenderer) { - - fun create(event: TimelineEvent, - highlight: Boolean, - callback: TimelineEventController.BaseCallback?): NoticeItem? { - - val text = buildNoticeText(event.root, event.senderName) ?: return null - val informationData = MessageInformationData( - eventId = event.root.eventId ?: "?", - senderId = event.root.senderId ?: "", - sendState = event.root.sendState, - avatarUrl = event.senderAvatar(), - memberName = event.senderName(), - showInformation = false - ) - return NoticeItem_() - .avatarRenderer(avatarRenderer) - .noticeText(text) - .informationData(informationData) - .highlighted(highlight) - .baseCallback(callback) - } - - private fun buildNoticeText(event: Event, senderName: String?): CharSequence? { - return when { - EventType.ENCRYPTION == event.getClearType() -> { - val content = event.content.toModel() ?: return null - stringProvider.getString(R.string.notice_end_to_end, senderName, content.algorithm) - } - else -> null - } - - } - - -} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt index f3a93a8d6d..57baf4fee8 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -47,27 +47,14 @@ import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.linkify.VectorLinkify import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.StringProvider -import im.vector.riotx.core.resources.UserPreferencesProvider import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider -import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar -import im.vector.riotx.features.home.room.detail.timeline.item.BlankItem_ -import im.vector.riotx.features.home.room.detail.timeline.item.DefaultItem -import im.vector.riotx.features.home.room.detail.timeline.item.DefaultItem_ -import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem -import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem_ -import im.vector.riotx.features.home.room.detail.timeline.item.MessageImageVideoItem -import im.vector.riotx.features.home.room.detail.timeline.item.MessageImageVideoItem_ -import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData -import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem -import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem_ -import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_ -import im.vector.riotx.features.home.room.detail.timeline.item.RedactedMessageItem -import im.vector.riotx.features.home.room.detail.timeline.item.RedactedMessageItem_ -import im.vector.riotx.features.home.room.detail.timeline.util.MessageInformationDataFactory +import im.vector.riotx.features.home.room.detail.timeline.item.* +import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory +import im.vector.riotx.features.home.room.detail.timeline.helper.MessageItemAttributesFactory import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.VideoContentRenderer @@ -75,14 +62,13 @@ import me.gujun.android.span.span import javax.inject.Inject class MessageItemFactory @Inject constructor( - private val avatarRenderer: AvatarRenderer, private val colorProvider: ColorProvider, private val timelineMediaSizeProvider: TimelineMediaSizeProvider, private val htmlRenderer: Lazy, private val stringProvider: StringProvider, - private val emojiCompatFontProvider: EmojiCompatFontProvider, private val imageContentRenderer: ImageContentRenderer, private val messageInformationDataFactory: MessageInformationDataFactory, + private val messageItemAttributesFactory: MessageItemAttributesFactory, private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder, private val noticeItemFactory: NoticeItemFactory) { @@ -98,36 +84,41 @@ class MessageItemFactory @Inject constructor( if (event.root.isRedacted()) { //message is redacted - return buildRedactedItem(informationData, highlight, callback) + val attributes = messageItemAttributesFactory.create(null, informationData, callback) + return buildRedactedItem(attributes, highlight) } val messageContent: MessageContent = event.getLastMessageContent() - ?: //Malformed content, we should echo something on screen - return DefaultItem_().text(stringProvider.getString(R.string.malformed_message)) + ?: //Malformed content, we should echo something on screen + return DefaultItem_().text(stringProvider.getString(R.string.malformed_message)) if (messageContent.relatesTo?.type == RelationType.REPLACE - || event.isEncrypted() && event.root.content.toModel()?.relatesTo?.type == RelationType.REPLACE + || event.isEncrypted() && event.root.content.toModel()?.relatesTo?.type == RelationType.REPLACE ) { // This is an edit event, we should it when debugging as a notice event return noticeItemFactory.create(event, highlight, callback) } + val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback) + // val all = event.root.toContent() // val ev = all.toModel() return when (messageContent) { is MessageEmoteContent -> buildEmoteMessageItem(messageContent, - informationData, - highlight, - callback) + informationData, + highlight, + callback, + attributes) is MessageTextContent -> buildTextMessageItem(messageContent, - informationData, - highlight, - callback) - is MessageImageContent -> buildImageMessageItem(messageContent, informationData, highlight, callback) - is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback) - is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback) - is MessageFileContent -> buildFileMessageItem(messageContent, informationData, highlight, callback) - is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, callback) + informationData, + highlight, + callback, + attributes) + is MessageImageContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes) + is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes) + is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes) + is MessageFileContent -> buildFileMessageItem(messageContent, informationData, highlight, callback, attributes) + is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, callback, attributes) else -> buildNotHandledMessageItem(messageContent, highlight) } } @@ -135,55 +126,29 @@ class MessageItemFactory @Inject constructor( private fun buildAudioMessageItem(messageContent: MessageAudioContent, informationData: MessageInformationData, highlight: Boolean, - callback: TimelineEventController.Callback?): MessageFileItem? { + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes): MessageFileItem? { return MessageFileItem_() - .avatarRenderer(avatarRenderer) - .colorProvider(colorProvider) - .informationData(informationData) + .attributes(attributes) .highlighted(highlight) - .avatarCallback(callback) - .readReceiptsCallback(callback) .filename(messageContent.body) .iconRes(R.drawable.filetype_audio) - .reactionPillCallback(callback) - .emojiTypeFace(emojiCompatFontProvider.typeface) - .cellClickListener( - DebouncedClickListener(View.OnClickListener { view: View -> - callback?.onEventCellClicked(informationData, messageContent, view) - })) .clickListener( DebouncedClickListener(View.OnClickListener { callback?.onAudioMessageClicked(messageContent) })) - .longClickListener { view -> - return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false - } } private fun buildFileMessageItem(messageContent: MessageFileContent, informationData: MessageInformationData, highlight: Boolean, - callback: TimelineEventController.Callback?): MessageFileItem? { + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes): MessageFileItem? { return MessageFileItem_() - .avatarRenderer(avatarRenderer) - .colorProvider(colorProvider) - .informationData(informationData) + .attributes(attributes) .highlighted(highlight) - .avatarCallback(callback) .filename(messageContent.body) - .reactionPillCallback(callback) - .readReceiptsCallback(callback) - .emojiTypeFace(emojiCompatFontProvider.typeface) .iconRes(R.drawable.filetype_attachment) - .cellClickListener( - DebouncedClickListener(View.OnClickListener { view -> - callback?.onEventCellClicked(informationData, messageContent, view) - })) - .longClickListener { view -> - return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false - } .clickListener( DebouncedClickListener(View.OnClickListener { _ -> callback?.onFileMessageClicked(informationData.eventId, messageContent) @@ -200,7 +165,8 @@ class MessageItemFactory @Inject constructor( private fun buildImageMessageItem(messageContent: MessageImageContent, informationData: MessageInformationData, highlight: Boolean, - callback: TimelineEventController.Callback?): MessageImageVideoItem? { + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes): MessageImageVideoItem? { val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize() val data = ImageContentRenderer.Data( @@ -215,42 +181,29 @@ class MessageItemFactory @Inject constructor( rotation = messageContent.info?.rotation ) return MessageImageVideoItem_() - .avatarRenderer(avatarRenderer) - .colorProvider(colorProvider) + .attributes(attributes) .imageContentRenderer(imageContentRenderer) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .playable(messageContent.info?.mimeType == "image/gif") - .informationData(informationData) .highlighted(highlight) - .avatarCallback(callback) .mediaData(data) - .reactionPillCallback(callback) - .readReceiptsCallback(callback) - .emojiTypeFace(emojiCompatFontProvider.typeface) .clickListener( DebouncedClickListener(View.OnClickListener { view -> callback?.onImageMessageClicked(messageContent, data, view) })) - .cellClickListener( - DebouncedClickListener(View.OnClickListener { view -> - callback?.onEventCellClicked(informationData, messageContent, view) - })) - .longClickListener { view -> - return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false - } } private fun buildVideoMessageItem(messageContent: MessageVideoContent, informationData: MessageInformationData, highlight: Boolean, - callback: TimelineEventController.Callback?): MessageImageVideoItem? { + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes): MessageImageVideoItem? { val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize() val thumbnailData = ImageContentRenderer.Data( filename = messageContent.body, url = messageContent.videoInfo?.thumbnailFile?.url - ?: messageContent.videoInfo?.thumbnailUrl, + ?: messageContent.videoInfo?.thumbnailUrl, elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), height = messageContent.videoInfo?.height, maxHeight = maxHeight, @@ -267,33 +220,20 @@ class MessageItemFactory @Inject constructor( ) return MessageImageVideoItem_() + .attributes(attributes) .imageContentRenderer(imageContentRenderer) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) - .avatarRenderer(avatarRenderer) - .colorProvider(colorProvider) .playable(true) - .informationData(informationData) .highlighted(highlight) - .avatarCallback(callback) .mediaData(thumbnailData) - .reactionPillCallback(callback) - .readReceiptsCallback(callback) - .emojiTypeFace(emojiCompatFontProvider.typeface) - .cellClickListener( - DebouncedClickListener(View.OnClickListener { view -> - callback?.onEventCellClicked(informationData, messageContent, view) - })) .clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view) } - .longClickListener { view -> - return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false - } } private fun buildTextMessageItem(messageContent: MessageTextContent, informationData: MessageInformationData, highlight: Boolean, - callback: TimelineEventController.Callback?): MessageTextItem? { + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes): MessageTextItem? { val bodyToUse = messageContent.formattedBody?.let { htmlRenderer.get().render(it.trim()) @@ -310,24 +250,10 @@ class MessageItemFactory @Inject constructor( message(linkifiedBody) } } - .avatarRenderer(avatarRenderer) - .informationData(informationData) - .colorProvider(colorProvider) + .attributes(attributes) .highlighted(highlight) - .avatarCallback(callback) .urlClickCallback(callback) - .reactionPillCallback(callback) - .readReceiptsCallback(callback) - .emojiTypeFace(emojiCompatFontProvider.typeface) - //click on the text - .cellClickListener( - DebouncedClickListener(View.OnClickListener { view -> - callback?.onEventCellClicked(informationData, messageContent, view) - })) - .longClickListener { view -> - return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false - } + //click on the text } private fun annotateWithEdited(linkifiedBody: CharSequence, @@ -356,16 +282,17 @@ class MessageItemFactory @Inject constructor( //nop } }, - editStart, - editEnd, - Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + editStart, + editEnd, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE) return spannable } private fun buildNoticeMessageItem(messageContent: MessageNoticeContent, informationData: MessageInformationData, highlight: Boolean, - callback: TimelineEventController.Callback?): MessageTextItem? { + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes): MessageTextItem? { val message = messageContent.body.let { val formattedBody = span { @@ -376,34 +303,17 @@ class MessageItemFactory @Inject constructor( linkifyBody(formattedBody, callback) } return MessageTextItem_() - .avatarRenderer(avatarRenderer) + .attributes(attributes) .message(message) - .colorProvider(colorProvider) - .informationData(informationData) .highlighted(highlight) - .avatarCallback(callback) - .reactionPillCallback(callback) .urlClickCallback(callback) - .readReceiptsCallback(callback) - .emojiTypeFace(emojiCompatFontProvider.typeface) - .memberClickListener( - DebouncedClickListener(View.OnClickListener { view -> - callback?.onMemberNameClicked(informationData) - })) - .cellClickListener( - DebouncedClickListener(View.OnClickListener { view -> - callback?.onEventCellClicked(informationData, messageContent, view) - })) - .longClickListener { view -> - return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false - } } private fun buildEmoteMessageItem(messageContent: MessageEmoteContent, informationData: MessageInformationData, highlight: Boolean, - callback: TimelineEventController.Callback?): MessageTextItem? { + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes): MessageTextItem? { val message = messageContent.body.let { val formattedBody = "* ${informationData.memberName} $it" @@ -418,43 +328,16 @@ class MessageItemFactory @Inject constructor( message(message) } } - .avatarRenderer(avatarRenderer) - .colorProvider(colorProvider) - .informationData(informationData) + .attributes(attributes) .highlighted(highlight) - .avatarCallback(callback) - .reactionPillCallback(callback) - .readReceiptsCallback(callback) .urlClickCallback(callback) - .emojiTypeFace(emojiCompatFontProvider.typeface) - .cellClickListener( - DebouncedClickListener(View.OnClickListener { view -> - callback?.onEventCellClicked(informationData, messageContent, view) - })) - .longClickListener { view -> - return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false - } } - private fun buildRedactedItem(informationData: MessageInformationData, - highlight: Boolean, - callback: TimelineEventController.Callback?): RedactedMessageItem? { + private fun buildRedactedItem(attributes: AbsMessageItem.Attributes, + highlight: Boolean): RedactedMessageItem? { return RedactedMessageItem_() - .avatarRenderer(avatarRenderer) - .colorProvider(colorProvider) - .informationData(informationData) + .attributes(attributes) .highlighted(highlight) - .avatarCallback(callback) - .readReceiptsCallback(callback) - .cellClickListener( - DebouncedClickListener(View.OnClickListener { view -> - callback?.onEventCellClicked(informationData, null, view) - })) - .longClickListener { view -> - return@longClickListener callback?.onEventLongClicked(informationData, null, view) - ?: false - } } private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): CharSequence { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt index f73a200133..6955cf3593 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt @@ -20,12 +20,9 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.format.NoticeEventFormatter -import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar -import im.vector.riotx.features.home.room.detail.timeline.helper.senderName -import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData +import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_ -import im.vector.riotx.features.home.room.detail.timeline.util.MessageInformationDataFactory import javax.inject.Inject class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEventFormatter, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index b1ae595ea0..9913f219f1 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -20,17 +20,11 @@ import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotx.core.epoxy.EmptyItem_ import im.vector.riotx.core.epoxy.VectorEpoxyModel -import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController -import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar -import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData -import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_ -import im.vector.riotx.features.home.room.detail.timeline.util.MessageInformationDataFactory import timber.log.Timber import javax.inject.Inject class TimelineItemFactory @Inject constructor(private val messageItemFactory: MessageItemFactory, - private val encryptionItemFactory: EncryptionItemFactory, private val encryptedItemFactory: EncryptedItemFactory, private val noticeItemFactory: NoticeItemFactory, private val defaultItemFactory: DefaultItemFactory, @@ -40,6 +34,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me nextEvent: TimelineEvent?, eventIdToHighlight: String?, callback: TimelineEventController.Callback?): VectorEpoxyModel<*> { + val highlight = event.root.eventId == eventIdToHighlight val computedModel = try { @@ -55,11 +50,11 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.CALL_HANGUP, EventType.CALL_ANSWER, EventType.REACTION, - EventType.REDACTION -> noticeItemFactory.create(event, highlight, callback) + EventType.REDACTION, + EventType.ENCRYPTION -> noticeItemFactory.create(event, highlight, callback) // State room create EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback) // Crypto - EventType.ENCRYPTION -> encryptionItemFactory.create(event, highlight, callback) EventType.ENCRYPTED -> { if (event.root.isRedacted()) { // Redacted event, let the MessageItemFactory handle it diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index 05ce7a9c19..2fcc1744da 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -23,6 +23,7 @@ import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.* import im.vector.matrix.android.api.session.room.model.call.CallInviteContent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent +import im.vector.matrix.android.internal.crypto.model.event.EncryptionEventContent import im.vector.riotx.R import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.features.home.room.detail.timeline.helper.senderName @@ -41,6 +42,7 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin EventType.CALL_INVITE, EventType.CALL_HANGUP, EventType.CALL_ANSWER -> formatCallEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) + EventType.ENCRYPTION -> formatEncryptionEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) EventType.MESSAGE, EventType.REACTION, EventType.REDACTION -> formatDebug(timelineEvent.root) @@ -60,6 +62,7 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin EventType.CALL_INVITE, EventType.CALL_HANGUP, EventType.CALL_ANSWER -> formatCallEvent(event, senderName) + EventType.ENCRYPTION -> formatEncryptionEvent(event, senderName) EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(event, senderName) else -> { Timber.v("Type $type not handled by this formatter") @@ -96,7 +99,7 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin private fun formatRoomHistoryVisibilityEvent(event: Event, senderName: String?): CharSequence? { val historyVisibility = event.getClearContent().toModel()?.historyVisibility - ?: return null + ?: return null val formattedVisibility = when (historyVisibility) { RoomHistoryVisibility.SHARED -> stringProvider.getString(R.string.notice_room_visibility_shared) @@ -146,7 +149,7 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin stringProvider.getString(R.string.notice_display_name_removed, event.senderId, prevEventContent?.displayName) else -> stringProvider.getString(R.string.notice_display_name_changed_from, - event.senderId, prevEventContent?.displayName, eventContent?.displayName) + event.senderId, prevEventContent?.displayName, eventContent?.displayName) } displayText.append(displayNameText) } @@ -173,7 +176,7 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin when { eventContent.thirdPartyInvite != null -> stringProvider.getString(R.string.notice_room_third_party_registered_invite, - targetDisplayName, eventContent.thirdPartyInvite?.displayName) + targetDisplayName, eventContent.thirdPartyInvite?.displayName) TextUtils.equals(event.stateKey, selfUserId) -> stringProvider.getString(R.string.notice_room_invite_you, senderDisplayName) event.stateKey.isNullOrEmpty() -> @@ -209,4 +212,9 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin } } + private fun formatEncryptionEvent(event: Event, senderName: String?): CharSequence? { + val eventContent: EncryptionEventContent = event.getClearContent().toModel() ?: return null + return stringProvider.getString(R.string.notice_end_to_end, senderName, eventContent.algorithm) + } + } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/util/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt similarity index 82% rename from vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/util/MessageInformationDataFactory.kt rename to vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index 71a7549b46..1e978bcfc6 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/util/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -1,20 +1,22 @@ /* - * 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. + + * 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.util +package im.vector.riotx.features.home.room.detail.timeline.helper import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.events.model.EventType @@ -62,7 +64,8 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId ?: "")) } - val displayReadMarker = event.hasReadMarker && event.readReceipts.find { it.user.userId == session.myUserId } == null + val displayReadMarker = event.hasReadMarker + && event.readReceipts.find { it.user.userId == session.myUserId } == null return MessageInformationData( eventId = eventId, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt new file mode 100644 index 0000000000..47b5094c95 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt @@ -0,0 +1,58 @@ +/* + + * 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.helper + +import android.view.View +import im.vector.matrix.android.api.session.room.model.message.MessageContent +import im.vector.riotx.EmojiCompatFontProvider +import im.vector.riotx.core.resources.ColorProvider +import im.vector.riotx.core.utils.DebouncedClickListener +import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import im.vector.riotx.features.home.room.detail.timeline.item.AbsMessageItem +import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData +import javax.inject.Inject + +class MessageItemAttributesFactory @Inject constructor( + private val avatarRenderer: AvatarRenderer, + private val colorProvider: ColorProvider, + private val emojiCompatFontProvider: EmojiCompatFontProvider) { + + fun create(messageContent: MessageContent?, informationData: MessageInformationData, callback: TimelineEventController.Callback?): AbsMessageItem.Attributes { + return AbsMessageItem.Attributes( + informationData = informationData, + avatarRenderer = avatarRenderer, + colorProvider = colorProvider, + itemLongClickListener = View.OnLongClickListener { view -> + callback?.onEventLongClicked(informationData, messageContent, view) ?: false + }, + itemClickListener = DebouncedClickListener(View.OnClickListener { view -> + callback?.onEventCellClicked(informationData, messageContent, view) + }), + memberClickListener = DebouncedClickListener(View.OnClickListener { view -> + callback?.onMemberNameClicked(informationData) + }), + reactionPillCallback = callback, + avatarCallback = callback, + readReceiptsCallback = callback, + emojiTypeFace = emojiCompatFontProvider.typeface + ) + + } + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index fa0a71bde2..b9c9d992f8 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -49,29 +49,6 @@ object TimelineDisplayableEvents { ) } -fun TimelineEvent.isDisplayable(showHiddenEvent: Boolean): Boolean { - val allowed = TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES.takeIf { showHiddenEvent } - ?: TimelineDisplayableEvents.DISPLAYABLE_TYPES - if (!allowed.contains(root.type)) { - return false - } - if (root.content.isNullOrEmpty()) { - return false - } - //Edits should be filtered out! - if (EventType.MESSAGE == root.type - && root.content.toModel()?.relatesTo?.type == RelationType.REPLACE) { - return false - } - return true -} -// -//fun List.filterDisplayableEvents(): List { -// return this.filter { -// it.isDisplayable() -// } -//} - fun TimelineEvent.senderAvatar(): String? { // We might have no avatar when user leave, so we try to get it from prevContent return senderAvatar @@ -131,10 +108,10 @@ fun List.prevSameTypeEvents(index: Int, minSize: Int): List.nextDisplayableEvent(index: Int, showHiddenEvent: Boolean): TimelineEvent? { +fun List.nextOrNull(index: Int): TimelineEvent? { return if (index >= size - 1) { null } else { - subList(index + 1, this.size).firstOrNull { it.isDisplayable(showHiddenEvent) } + subList(index + 1, this.size).firstOrNull() } } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt index 95d9b6f43b..eb3dc44e56 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt @@ -28,9 +28,10 @@ class TimelineEventVisibilityStateChangedListener(private val callback: Timeline override fun onVisibilityStateChanged(visibilityState: Int) { if (visibilityState == VisibilityState.VISIBLE) { callback?.onEventVisible(event) + } else if (visibilityState == VisibilityState.INVISIBLE) { + callback?.onEventInvisible(event) } } - } @@ -40,9 +41,9 @@ class MergedTimelineEventVisibilityStateChangedListener(private val callback: Ti override fun onVisibilityStateChanged(visibilityState: Int) { if (visibilityState == VisibilityState.VISIBLE) { - events.forEach { - callback?.onEventVisible(it) - } + events.forEach { callback?.onEventVisible(it) } + } else if (visibilityState == VisibilityState.INVISIBLE) { + events.forEach { callback?.onEventInvisible(it) } } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt index 570daf669c..5431b4ca85 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -32,6 +32,7 @@ import com.airbnb.epoxy.EpoxyAttribute import im.vector.matrix.android.api.session.room.send.SendState import im.vector.riotx.R import im.vector.riotx.core.resources.ColorProvider +import im.vector.riotx.core.ui.views.ReadMarkerView import im.vector.riotx.core.ui.views.ReadReceiptsView import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.core.utils.DimensionUtils.dpToPx @@ -43,63 +44,42 @@ import im.vector.riotx.features.ui.getMessageTextColor abstract class AbsMessageItem : BaseEventItem() { @EpoxyAttribute - lateinit var informationData: MessageInformationData - - @EpoxyAttribute - lateinit var avatarRenderer: AvatarRenderer - - @EpoxyAttribute - lateinit var colorProvider: ColorProvider - - @EpoxyAttribute - var longClickListener: View.OnLongClickListener? = null - - @EpoxyAttribute - var cellClickListener: View.OnClickListener? = null - - @EpoxyAttribute - var memberClickListener: View.OnClickListener? = null - - @EpoxyAttribute - var emojiTypeFace: Typeface? = null - - @EpoxyAttribute - var reactionPillCallback: TimelineEventController.ReactionPillCallback? = null - - @EpoxyAttribute - var avatarCallback: TimelineEventController.AvatarCallback? = null - - @EpoxyAttribute - var readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null + lateinit var attributes: Attributes private val _avatarClickListener = DebouncedClickListener(View.OnClickListener { - avatarCallback?.onAvatarClicked(informationData) + attributes.avatarCallback?.onAvatarClicked(attributes.informationData) }) private val _memberNameClickListener = DebouncedClickListener(View.OnClickListener { - avatarCallback?.onMemberNameClicked(informationData) + attributes.avatarCallback?.onMemberNameClicked(attributes.informationData) }) private val _readReceiptsClickListener = DebouncedClickListener(View.OnClickListener { - readReceiptsCallback?.onReadReceiptsClicked(informationData.readReceipts) + attributes.readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts) }) + private val _readMarkerCallback = object : ReadMarkerView.Callback { + override fun onReadMarkerDisplayed() { + attributes.readReceiptsCallback?.onReadMarkerLongDisplayed(attributes.informationData) + } + } + var reactionClickListener: ReactionButton.ReactedListener = object : ReactionButton.ReactedListener { override fun onReacted(reactionButton: ReactionButton) { - reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString, true) + attributes.reactionPillCallback?.onClickOnReactionPill(attributes.informationData, reactionButton.reactionString, true) } override fun onUnReacted(reactionButton: ReactionButton) { - reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString, false) + attributes.reactionPillCallback?.onClickOnReactionPill(attributes.informationData, reactionButton.reactionString, false) } override fun onLongClick(reactionButton: ReactionButton) { - reactionPillCallback?.onLongClickOnReactionPill(informationData, reactionButton.reactionString) + attributes.reactionPillCallback?.onLongClickOnReactionPill(attributes.informationData, reactionButton.reactionString) } } override fun bind(holder: H) { super.bind(holder) - if (informationData.showInformation) { + if (attributes.informationData.showInformation) { holder.avatarImageView.layoutParams = holder.avatarImageView.layoutParams?.apply { val size = dpToPx(avatarStyle.avatarSizeDP, holder.view.context) height = size @@ -110,13 +90,13 @@ abstract class AbsMessageItem : BaseEventItem() { holder.memberNameView.visibility = View.VISIBLE holder.memberNameView.setOnClickListener(_memberNameClickListener) holder.timeView.visibility = View.VISIBLE - holder.timeView.text = informationData.time - holder.memberNameView.text = informationData.memberName - avatarRenderer.render(informationData.avatarUrl, informationData.senderId, informationData.memberName?.toString(), holder.avatarImageView) - holder.view.setOnClickListener(cellClickListener) - holder.view.setOnLongClickListener(longClickListener) - holder.avatarImageView.setOnLongClickListener(longClickListener) - holder.memberNameView.setOnLongClickListener(longClickListener) + holder.timeView.text = attributes.informationData.time + holder.memberNameView.text = attributes.informationData.memberName + attributes.avatarRenderer.render(attributes.informationData.avatarUrl, attributes.informationData.senderId, attributes.informationData.memberName?.toString(), holder.avatarImageView) + holder.view.setOnClickListener(attributes.itemClickListener) + holder.view.setOnLongClickListener(attributes.itemLongClickListener) + holder.avatarImageView.setOnLongClickListener(attributes.itemLongClickListener) + holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener) } else { holder.avatarImageView.setOnClickListener(null) holder.memberNameView.setOnClickListener(null) @@ -128,11 +108,10 @@ abstract class AbsMessageItem : BaseEventItem() { holder.avatarImageView.setOnLongClickListener(null) holder.memberNameView.setOnLongClickListener(null) } + holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) + holder.readMarkerView.bindView(attributes.informationData, _readMarkerCallback) - holder.readMarkerView.isVisible = informationData.displayReadMarker - holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener) - - if (!shouldShowReactionAtBottom() || informationData.orderedReactionList.isNullOrEmpty()) { + if (!shouldShowReactionAtBottom() || attributes.informationData.orderedReactionList.isNullOrEmpty()) { holder.reactionWrapper?.isVisible = false } else { //inflate if needed @@ -144,7 +123,7 @@ abstract class AbsMessageItem : BaseEventItem() { //clear all reaction buttons (but not the Flow helper!) holder.reactionWrapper?.children?.forEach { (it as? ReactionButton)?.isGone = true } val idToRefInFlow = ArrayList() - informationData.orderedReactionList?.chunked(8)?.firstOrNull()?.forEachIndexed { index, reaction -> + attributes.informationData.orderedReactionList?.chunked(8)?.firstOrNull()?.forEachIndexed { index, reaction -> (holder.reactionWrapper?.children?.elementAtOrNull(index) as? ReactionButton)?.let { reactionButton -> reactionButton.isVisible = true reactionButton.reactedListener = reactionClickListener @@ -152,7 +131,7 @@ abstract class AbsMessageItem : BaseEventItem() { idToRefInFlow.add(reactionButton.id) reactionButton.reactionString = reaction.key reactionButton.reactionCount = reaction.count - reactionButton.emojiTypeFace = emojiTypeFace + reactionButton.emojiTypeFace = attributes.emojiTypeFace reactionButton.setChecked(reaction.addedByMe) reactionButton.isEnabled = reaction.synced } @@ -163,27 +142,48 @@ abstract class AbsMessageItem : BaseEventItem() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && !holder.view.isInLayout) { holder.reactionFlowHelper?.requestLayout() } - holder.reactionWrapper?.setOnLongClickListener(longClickListener) + holder.reactionWrapper?.setOnLongClickListener(attributes.itemLongClickListener) } } + override fun unbind(holder: H) { + holder.readMarkerView.unbind() + super.unbind(holder) + } + open fun shouldShowReactionAtBottom(): Boolean { return true } protected open fun renderSendState(root: View, textView: TextView?, failureIndicator: ImageView? = null) { - root.isClickable = informationData.sendState.isSent() - val state = if (informationData.hasPendingEdits) SendState.UNSENT else informationData.sendState - textView?.setTextColor(colorProvider.getMessageTextColor(state)) - failureIndicator?.isVisible = informationData.sendState.hasFailed() + root.isClickable = attributes.informationData.sendState.isSent() + val state = if (attributes.informationData.hasPendingEdits) SendState.UNSENT else attributes.informationData.sendState + textView?.setTextColor(attributes.colorProvider.getMessageTextColor(state)) + failureIndicator?.isVisible = attributes.informationData.sendState.hasFailed() } + /** + * This class holds all the common attributes for message items. + */ + data class Attributes( + val informationData: MessageInformationData, + val avatarRenderer: AvatarRenderer, + val colorProvider: ColorProvider, + val itemLongClickListener: View.OnLongClickListener? = null, + val itemClickListener: View.OnClickListener? = null, + val memberClickListener: View.OnClickListener? = null, + val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null, + val avatarCallback: TimelineEventController.AvatarCallback? = null, + val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, + val emojiTypeFace: Typeface? = null + ) + abstract class Holder(@IdRes stubId: Int) : BaseHolder(stubId) { val avatarImageView by bind(R.id.messageAvatarImageView) val memberNameView by bind(R.id.messageMemberNameView) val timeView by bind(R.id.messageTimeView) val readReceiptsView by bind(R.id.readReceiptsView) - val readMarkerView by bind(R.id.readMarkerView) + val readMarkerView by bind(R.id.readMarkerView) var reactionWrapper: ViewGroup? = null var reactionFlowHelper: Flow? = null } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt index 843f52b34c..5621f6047a 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt @@ -24,7 +24,10 @@ import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyHolder import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.platform.CheckableView +import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.utils.DimensionUtils.dpToPx +import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController /** * Children must override getViewType() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageImageVideoItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageImageVideoItem.kt index 6f713b17fe..94e4835862 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageImageVideoItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageImageVideoItem.kt @@ -43,21 +43,21 @@ abstract class MessageImageVideoItem : AbsMessageItem() { holder.messageView.setTextFuture(textFuture) renderSendState(holder.messageView, holder.messageView) - holder.messageView.setOnClickListener(cellClickListener) - holder.messageView.setOnLongClickListener(longClickListener) + holder.messageView.setOnClickListener(attributes.itemClickListener) + holder.messageView.setOnLongClickListener(attributes.itemLongClickListener) findPillsAndProcess { it.bind(holder.messageView) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt index dd42dc7b66..aad090db05 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt @@ -19,10 +19,10 @@ package im.vector.riotx.features.home.room.detail.timeline.item import android.view.View import android.widget.ImageView import android.widget.TextView -import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.riotx.R +import im.vector.riotx.core.ui.views.ReadMarkerView import im.vector.riotx.core.ui.views.ReadReceiptsView import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.features.home.AvatarRenderer @@ -54,6 +54,12 @@ abstract class NoticeItem : BaseEventItem() { readReceiptsCallback?.onReadReceiptsClicked(informationData.readReceipts) }) + private val _readMarkerCallback = object : ReadMarkerView.Callback { + override fun onReadMarkerDisplayed() { + readReceiptsCallback?.onReadMarkerLongDisplayed(informationData) + } + } + override fun bind(holder: Holder) { super.bind(holder) holder.noticeTextView.text = noticeText @@ -61,12 +67,17 @@ abstract class NoticeItem : BaseEventItem() { informationData.avatarUrl, informationData.senderId, informationData.memberName?.toString() - ?: informationData.senderId, + ?: informationData.senderId, holder.avatarImageView ) holder.view.setOnLongClickListener(longClickListener) holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener) - holder.readMarkerView.isVisible = informationData.displayReadMarker + holder.readMarkerView.bindView(informationData, _readMarkerCallback) + } + + override fun unbind(holder: Holder) { + holder.readMarkerView.unbind() + super.unbind(holder) } override fun getViewType() = STUB_ID @@ -75,7 +86,7 @@ abstract class NoticeItem : BaseEventItem() { val avatarImageView by bind(R.id.itemNoticeAvatarView) val noticeTextView by bind(R.id.itemNoticeTextView) val readReceiptsView by bind(R.id.readReceiptsView) - val readMarkerView by bind(R.id.readMarkerView) + val readMarkerView by bind(R.id.readMarkerView) } companion object { diff --git a/vector/src/main/res/anim/unread_marker_anim.xml b/vector/src/main/res/anim/unread_marker_anim.xml index 0c7ddab398..9e61c80c9d 100644 --- a/vector/src/main/res/anim/unread_marker_anim.xml +++ b/vector/src/main/res/anim/unread_marker_anim.xml @@ -1,7 +1,6 @@ \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml index 1d37628210..d7ce9963a0 100644 --- a/vector/src/main/res/layout/fragment_room_detail.xml +++ b/vector/src/main/res/layout/fragment_room_detail.xml @@ -6,6 +6,29 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + + + + - - - - - + - - - + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/syncProgressBarWrap" /> + + - \ No newline at end of file diff --git a/vector/src/main/res/layout/item_timeline_event_base.xml b/vector/src/main/res/layout/item_timeline_event_base.xml index ea4cfd5d4a..4eb9be0b9f 100644 --- a/vector/src/main/res/layout/item_timeline_event_base.xml +++ b/vector/src/main/res/layout/item_timeline_event_base.xml @@ -128,17 +128,20 @@ android:layout_height="wrap_content" android:layout_marginEnd="8dp" android:layout_marginBottom="4dp" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@+id/readMarkerView" app:layout_constraintEnd_toEndOf="parent" /> - + app:layout_constraintStart_toStartOf="parent" /> \ No newline at end of file diff --git a/vector/src/main/res/layout/item_timeline_event_base_noinfo.xml b/vector/src/main/res/layout/item_timeline_event_base_noinfo.xml index ad6999c5ee..fc4a527d03 100644 --- a/vector/src/main/res/layout/item_timeline_event_base_noinfo.xml +++ b/vector/src/main/res/layout/item_timeline_event_base_noinfo.xml @@ -58,18 +58,21 @@ android:layout_height="wrap_content" android:layout_marginEnd="8dp" android:layout_marginBottom="4dp" - app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintBottom_toTopOf="@+id/readMarkerView" app:layout_constraintEnd_toEndOf="parent" /> - + app:layout_constraintStart_toStartOf="parent" /> \ No newline at end of file diff --git a/vector/src/main/res/layout/item_timeline_event_merged_header_stub.xml b/vector/src/main/res/layout/item_timeline_event_merged_header_stub.xml index 1dd5a61104..46c84aa4e7 100644 --- a/vector/src/main/res/layout/item_timeline_event_merged_header_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_merged_header_stub.xml @@ -40,7 +40,7 @@ android:layout_width="0dp" android:layout_height="1dp" android:layout_marginTop="4dp" - android:background="?attr/colorAccent" + android:background="?attr/riotx_header_panel_background" app:layout_constraintEnd_toEndOf="@id/itemMergedExpandTextView" app:layout_constraintStart_toStartOf="@id/itemMergedAvatarListView" app:layout_constraintTop_toBottomOf="@id/itemMergedExpandTextView" /> diff --git a/vector/src/main/res/layout/view_jump_to_read_marker.xml b/vector/src/main/res/layout/view_jump_to_read_marker.xml new file mode 100644 index 0000000000..35e14a649d --- /dev/null +++ b/vector/src/main/res/layout/view_jump_to_read_marker.xml @@ -0,0 +1,41 @@ + + + + + + + + diff --git a/vector/src/main/res/layout/view_read_marker.xml b/vector/src/main/res/layout/view_read_marker.xml new file mode 100644 index 0000000000..e3cbc6ba06 --- /dev/null +++ b/vector/src/main/res/layout/view_read_marker.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + From b8ebe3570bb55733c8eedfbc8b399f1af835e029 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 11 Sep 2019 18:04:17 +0200 Subject: [PATCH 03/22] Timeline: refact epoxy attributes --- .../timeline/TimelineEventController.kt | 59 ++-------- .../timeline/factory/DefaultItemFactory.kt | 7 +- .../timeline/factory/EncryptedItemFactory.kt | 6 +- .../factory/MergedHeaderItemFactory.kt | 103 ++++++++++++++++++ .../timeline/factory/MessageItemFactory.kt | 45 +++++--- .../timeline/factory/NoticeItemFactory.kt | 24 ++-- .../timeline/helper/AvatarSizeProvider.kt | 46 ++++++++ .../helper/MessageItemAttributesFactory.kt | 7 +- .../detail/timeline/item/AbsMessageItem.kt | 28 ++--- .../detail/timeline/item/BaseEventItem.kt | 22 +--- .../timeline/item/EventItemAttributes.kt | 18 +++ .../detail/timeline/item/MergedHeaderItem.kt | 42 ++++--- .../room/detail/timeline/item/NoticeItem.kt | 40 ++++--- 13 files changed, 286 insertions(+), 161 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/AvatarSizeProvider.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/EventItemAttributes.kt diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index ffc573a634..9b9172a6f9 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -32,11 +32,13 @@ 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.MergedHeaderItemFactory 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.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 @@ -47,7 +49,7 @@ import javax.inject.Inject class TimelineEventController @Inject constructor(private val dateFormatter: VectorDateFormatter, private val timelineItemFactory: TimelineItemFactory, private val timelineMediaSizeProvider: TimelineMediaSizeProvider, - private val avatarRenderer: AvatarRenderer, + private val mergedHeaderItemFactory: MergedHeaderItemFactory, @TimelineEventControllerHandler private val backgroundHandler: Handler ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener { @@ -89,8 +91,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec fun onUrlLongClicked(url: String): Boolean } - private val collapsedEventIds = linkedSetOf() - private val mergeItemCollapseStates = HashMap() private val modelCache = arrayListOf() private var currentSnapshot: List = emptyList() @@ -231,7 +231,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } return modelCache .map { - val eventModel = if (it == null || collapsedEventIds.contains(it.localId)) { + val eventModel = if (it == null || mergedHeaderItemFactory.isCollapsed(it.localId)) { null } else { it.eventModel @@ -255,7 +255,9 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec it.id(event.localId) it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) } - val mergedHeaderModel = buildMergedHeaderItem(event, nextEvent, items, addDaySeparator, currentPosition) + val mergedHeaderModel = mergedHeaderItemFactory.create(event, nextEvent, items, addDaySeparator, currentPosition, callback){ + requestModelBuild() + } val daySeparatorItem = buildDaySeparatorItem(addDaySeparator, date) return CacheItemData(event.localId, event.root.eventId, eventModel, mergedHeaderModel, daySeparatorItem) @@ -270,53 +272,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } - // 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 */ diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt index 05e4007e04..fbe77d6ac2 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt @@ -17,11 +17,12 @@ package im.vector.riotx.features.home.room.detail.timeline.factory import im.vector.matrix.android.api.session.room.timeline.TimelineEvent +import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.riotx.features.home.room.detail.timeline.item.DefaultItem import im.vector.riotx.features.home.room.detail.timeline.item.DefaultItem_ import javax.inject.Inject -class DefaultItemFactory @Inject constructor(){ +class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: AvatarSizeProvider) { fun create(event: TimelineEvent, highlight: Boolean, exception: Exception? = null): DefaultItem? { val text = if (exception == null) { @@ -30,8 +31,10 @@ class DefaultItemFactory @Inject constructor(){ "an exception occurred when rendering the event ${event.root.eventId}" } return DefaultItem_() - .text(text) + .leftGuideline(avatarSizeProvider.leftGuideline) .highlighted(highlight) + .text(text) + } } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt index 938ac4673e..82ac4dc4d2 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt @@ -23,7 +23,6 @@ import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.StringProvider -import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem_ import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory @@ -55,7 +54,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat } val message = stringProvider.getString(R.string.encrypted_message).takeIf { cryptoError == null } - ?: stringProvider.getString(R.string.notice_crypto_unable_to_decrypt, errorDescription) + ?: stringProvider.getString(R.string.notice_crypto_unable_to_decrypt, errorDescription) val spannableStr = span(message) { textStyle = "italic" textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary) @@ -66,11 +65,10 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat val informationData = messageInformationDataFactory.create(event, nextEvent) val attributes = attributesFactory.create(null, informationData, callback) return MessageTextItem_() + .highlighted(highlight) .attributes(attributes) .message(spannableStr) - .highlighted(highlight) .urlClickCallback(callback) - } else -> null } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt new file mode 100644 index 0000000000..06514e5973 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -0,0 +1,103 @@ +/* + * 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.factory + +import im.vector.matrix.android.api.session.room.timeline.TimelineEvent +import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider +import im.vector.riotx.features.home.room.detail.timeline.helper.MergedTimelineEventVisibilityStateChangedListener +import im.vector.riotx.features.home.room.detail.timeline.helper.canBeMerged +import im.vector.riotx.features.home.room.detail.timeline.helper.prevSameTypeEvents +import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar +import im.vector.riotx.features.home.room.detail.timeline.helper.senderName +import im.vector.riotx.features.home.room.detail.timeline.item.MergedHeaderItem +import im.vector.riotx.features.home.room.detail.timeline.item.MergedHeaderItem_ +import javax.inject.Inject + +class MergedHeaderItemFactory @Inject constructor(private val avatarRenderer: AvatarRenderer, + private val avatarSizeProvider: AvatarSizeProvider) { + + private val collapsedEventIds = linkedSetOf() + private val mergeItemCollapseStates = HashMap() + + fun create(event: TimelineEvent, + nextEvent: TimelineEvent?, + items: List, + addDaySeparator: Boolean, + currentPosition: Int, + callback: TimelineEventController.Callback?, + requestModelBuild: () -> Unit) + : 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() } + val attributes = MergedHeaderItem.Attributes( + isCollapsed = isCollapsed, + mergeData = mergedData, + avatarRenderer = avatarRenderer, + onCollapsedStateChanged = { + mergeItemCollapseStates[event.localId] = it + requestModelBuild() + } + ) + MergedHeaderItem_() + .id(mergeId) + .leftGuideline(avatarSizeProvider.leftGuideline) + .attributes(attributes) + .also { + it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents)) + } + } + } + } + + fun isCollapsed(localId: Long): Boolean { + return collapsedEventIds.contains(localId) + } + + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 57baf4fee8..24d5abfd94 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -41,16 +41,15 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent -import im.vector.riotx.EmojiCompatFontProvider import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.linkify.VectorLinkify import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.utils.DebouncedClickListener -import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder +import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory @@ -70,7 +69,8 @@ class MessageItemFactory @Inject constructor( private val messageInformationDataFactory: MessageInformationDataFactory, private val messageItemAttributesFactory: MessageItemAttributesFactory, private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder, - private val noticeItemFactory: NoticeItemFactory) { + private val noticeItemFactory: NoticeItemFactory, + private val avatarSizeProvider: AvatarSizeProvider) { fun create(event: TimelineEvent, @@ -90,11 +90,11 @@ class MessageItemFactory @Inject constructor( val messageContent: MessageContent = event.getLastMessageContent() - ?: //Malformed content, we should echo something on screen - return DefaultItem_().text(stringProvider.getString(R.string.malformed_message)) + ?: //Malformed content, we should echo something on screen + return DefaultItem_().text(stringProvider.getString(R.string.malformed_message)) if (messageContent.relatesTo?.type == RelationType.REPLACE - || event.isEncrypted() && event.root.content.toModel()?.relatesTo?.type == RelationType.REPLACE + || event.isEncrypted() && event.root.content.toModel()?.relatesTo?.type == RelationType.REPLACE ) { // This is an edit event, we should it when debugging as a notice event return noticeItemFactory.create(event, highlight, callback) @@ -105,15 +105,15 @@ class MessageItemFactory @Inject constructor( // val ev = all.toModel() return when (messageContent) { is MessageEmoteContent -> buildEmoteMessageItem(messageContent, - informationData, - highlight, - callback, - attributes) + informationData, + highlight, + callback, + attributes) is MessageTextContent -> buildTextMessageItem(messageContent, - informationData, - highlight, - callback, - attributes) + informationData, + highlight, + callback, + attributes) is MessageImageContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes) @@ -131,6 +131,7 @@ class MessageItemFactory @Inject constructor( return MessageFileItem_() .attributes(attributes) .highlighted(highlight) + .leftGuideline(avatarSizeProvider.leftGuideline) .filename(messageContent.body) .iconRes(R.drawable.filetype_audio) .clickListener( @@ -146,6 +147,7 @@ class MessageItemFactory @Inject constructor( attributes: AbsMessageItem.Attributes): MessageFileItem? { return MessageFileItem_() .attributes(attributes) + .leftGuideline(avatarSizeProvider.leftGuideline) .highlighted(highlight) .filename(messageContent.body) .iconRes(R.drawable.filetype_attachment) @@ -158,6 +160,7 @@ class MessageItemFactory @Inject constructor( private fun buildNotHandledMessageItem(messageContent: MessageContent, highlight: Boolean): DefaultItem? { val text = "${messageContent.type} message events are not yet handled" return DefaultItem_() + .leftGuideline(avatarSizeProvider.leftGuideline) .text(text) .highlighted(highlight) } @@ -182,6 +185,7 @@ class MessageItemFactory @Inject constructor( ) return MessageImageVideoItem_() .attributes(attributes) + .leftGuideline(avatarSizeProvider.leftGuideline) .imageContentRenderer(imageContentRenderer) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .playable(messageContent.info?.mimeType == "image/gif") @@ -203,7 +207,7 @@ class MessageItemFactory @Inject constructor( val thumbnailData = ImageContentRenderer.Data( filename = messageContent.body, url = messageContent.videoInfo?.thumbnailFile?.url - ?: messageContent.videoInfo?.thumbnailUrl, + ?: messageContent.videoInfo?.thumbnailUrl, elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), height = messageContent.videoInfo?.height, maxHeight = maxHeight, @@ -220,6 +224,7 @@ class MessageItemFactory @Inject constructor( ) return MessageImageVideoItem_() + .leftGuideline(avatarSizeProvider.leftGuideline) .attributes(attributes) .imageContentRenderer(imageContentRenderer) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) @@ -250,6 +255,7 @@ class MessageItemFactory @Inject constructor( message(linkifiedBody) } } + .leftGuideline(avatarSizeProvider.leftGuideline) .attributes(attributes) .highlighted(highlight) .urlClickCallback(callback) @@ -282,9 +288,9 @@ class MessageItemFactory @Inject constructor( //nop } }, - editStart, - editEnd, - Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + editStart, + editEnd, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE) return spannable } @@ -303,6 +309,7 @@ class MessageItemFactory @Inject constructor( linkifyBody(formattedBody, callback) } return MessageTextItem_() + .leftGuideline(avatarSizeProvider.leftGuideline) .attributes(attributes) .message(message) .highlighted(highlight) @@ -328,6 +335,7 @@ class MessageItemFactory @Inject constructor( message(message) } } + .leftGuideline(avatarSizeProvider.leftGuideline) .attributes(attributes) .highlighted(highlight) .urlClickCallback(callback) @@ -336,6 +344,7 @@ class MessageItemFactory @Inject constructor( private fun buildRedactedItem(attributes: AbsMessageItem.Attributes, highlight: Boolean): RedactedMessageItem? { return RedactedMessageItem_() + .leftGuideline(avatarSizeProvider.leftGuideline) .attributes(attributes) .highlighted(highlight) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt index 6955cf3593..8663f87409 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt @@ -20,28 +20,34 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.format.NoticeEventFormatter +import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_ import javax.inject.Inject -class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEventFormatter, - private val avatarRenderer: AvatarRenderer, - private val informationDataFactory: MessageInformationDataFactory) { +class NoticeItemFactory @Inject constructor( + private val eventFormatter: NoticeEventFormatter, + private val avatarRenderer: AvatarRenderer, + private val informationDataFactory: MessageInformationDataFactory, + private val avatarSizeProvider: AvatarSizeProvider +) { fun create(event: TimelineEvent, highlight: Boolean, callback: TimelineEventController.Callback?): NoticeItem? { val formattedText = eventFormatter.format(event) ?: return null val informationData = informationDataFactory.create(event, null) - + val attributes = NoticeItem.Attributes( + avatarRenderer = avatarRenderer, + informationData = informationData, + noticeText = formattedText, + callback = callback + ) return NoticeItem_() - .avatarRenderer(avatarRenderer) - .noticeText(formattedText) + .leftGuideline(avatarSizeProvider.leftGuideline) .highlighted(highlight) - .informationData(informationData) - .baseCallback(callback) - .readReceiptsCallback(callback) + .attributes(attributes) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/AvatarSizeProvider.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/AvatarSizeProvider.kt new file mode 100644 index 0000000000..9fcfdcfdf6 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/AvatarSizeProvider.kt @@ -0,0 +1,46 @@ +/* + * 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.helper + +import androidx.appcompat.app.AppCompatActivity +import im.vector.riotx.core.utils.DimensionUtils.dpToPx +import javax.inject.Inject + +class AvatarSizeProvider @Inject constructor(private val context: AppCompatActivity) { + + private val avatarStyle = AvatarStyle.SMALL + + val leftGuideline: Int by lazy { + dpToPx(avatarStyle.avatarSizeDP + 8, context) + } + + val avatarSize: Int by lazy { + dpToPx(avatarStyle.avatarSizeDP, context) + } + + companion object { + + enum class AvatarStyle(val avatarSizeDP: Int) { + BIG(50), + MEDIUM(40), + SMALL(30), + NONE(0) + } + + } + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt index 47b5094c95..d69676cb2f 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt @@ -31,10 +31,15 @@ import javax.inject.Inject class MessageItemAttributesFactory @Inject constructor( private val avatarRenderer: AvatarRenderer, private val colorProvider: ColorProvider, + private val avatarSizeProvider: AvatarSizeProvider, private val emojiCompatFontProvider: EmojiCompatFontProvider) { - fun create(messageContent: MessageContent?, informationData: MessageInformationData, callback: TimelineEventController.Callback?): AbsMessageItem.Attributes { + fun create(messageContent: MessageContent?, + informationData: MessageInformationData, + callback: TimelineEventController.Callback?): AbsMessageItem.Attributes { + return AbsMessageItem.Attributes( + avatarSize = avatarSizeProvider.avatarSize, informationData = informationData, avatarRenderer = avatarRenderer, colorProvider = colorProvider, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt index 5431b4ca85..913b1be466 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -81,9 +81,8 @@ abstract class AbsMessageItem : BaseEventItem() { super.bind(holder) if (attributes.informationData.showInformation) { holder.avatarImageView.layoutParams = holder.avatarImageView.layoutParams?.apply { - val size = dpToPx(avatarStyle.avatarSizeDP, holder.view.context) - height = size - width = size + height = attributes.avatarSize + width = attributes.avatarSize } holder.avatarImageView.visibility = View.VISIBLE holder.avatarImageView.setOnClickListener(_avatarClickListener) @@ -162,10 +161,21 @@ abstract class AbsMessageItem : BaseEventItem() { failureIndicator?.isVisible = attributes.informationData.sendState.hasFailed() } + abstract class Holder(@IdRes stubId: Int) : BaseHolder(stubId) { + val avatarImageView by bind(R.id.messageAvatarImageView) + val memberNameView by bind(R.id.messageMemberNameView) + val timeView by bind(R.id.messageTimeView) + val readReceiptsView by bind(R.id.readReceiptsView) + val readMarkerView by bind(R.id.readMarkerView) + var reactionWrapper: ViewGroup? = null + var reactionFlowHelper: Flow? = null + } + /** - * This class holds all the common attributes for message items. + * This class holds all the common attributes for timeline items. */ data class Attributes( + val avatarSize: Int, val informationData: MessageInformationData, val avatarRenderer: AvatarRenderer, val colorProvider: ColorProvider, @@ -178,14 +188,4 @@ abstract class AbsMessageItem : BaseEventItem() { val emojiTypeFace: Typeface? = null ) - abstract class Holder(@IdRes stubId: Int) : BaseHolder(stubId) { - val avatarImageView by bind(R.id.messageAvatarImageView) - val memberNameView by bind(R.id.messageMemberNameView) - val timeView by bind(R.id.messageTimeView) - val readReceiptsView by bind(R.id.readReceiptsView) - val readMarkerView by bind(R.id.readMarkerView) - var reactionWrapper: ViewGroup? = null - var reactionFlowHelper: Flow? = null - } - } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt index 5621f6047a..efdc6d06c6 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt @@ -24,28 +24,23 @@ import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyHolder import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.platform.CheckableView -import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.utils.DimensionUtils.dpToPx -import im.vector.riotx.features.home.AvatarRenderer -import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import org.w3c.dom.Attr /** * Children must override getViewType() */ abstract class BaseEventItem : VectorEpoxyModel() { - var avatarStyle: AvatarStyle = AvatarStyle.SMALL - // To use for instance when opening a permalink with an eventId @EpoxyAttribute var highlighted: Boolean = false + @EpoxyAttribute + open var leftGuideline: Int = 0 override fun bind(holder: H) { super.bind(holder) - //optimize? - val px = dpToPx(avatarStyle.avatarSizeDP + 8, holder.view.context) - holder.leftGuideline.setGuidelineBegin(px) - + holder.leftGuideline.setGuidelineBegin(leftGuideline) holder.checkableBackground.isChecked = highlighted } @@ -63,13 +58,4 @@ abstract class BaseEventItem : VectorEpoxyModel } } - companion object { - - enum class AvatarStyle(val avatarSizeDP: Int) { - BIG(50), - MEDIUM(40), - SMALL(30), - NONE(0) - } - } } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/EventItemAttributes.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/EventItemAttributes.kt new file mode 100644 index 0000000000..5c1ab5b347 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/EventItemAttributes.kt @@ -0,0 +1,18 @@ +/* + * 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.item + diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt index 4f26f9bb11..a36d9ab7cd 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt @@ -21,29 +21,20 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import androidx.core.view.children +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass import im.vector.riotx.R import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +@EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo) +abstract class MergedHeaderItem : BaseEventItem() { -data class MergedHeaderItem(private val isCollapsed: Boolean, - private val mergeId: String, - private val mergeData: List, - private val avatarRenderer: AvatarRenderer, - private val onCollapsedStateChanged: (Boolean) -> Unit -) : BaseEventItem() { + @EpoxyAttribute + lateinit var attributes: Attributes - private val distinctMergeData = mergeData.distinctBy { it.userId } - - init { - id(mergeId) - } - - override fun getDefaultLayout(): Int { - return R.layout.item_timeline_event_base_noinfo - } - - override fun createNewHolder(): Holder { - return Holder() + private val distinctMergeData by lazy { + attributes.mergeData.distinctBy { it.userId } } override fun getViewType() = STUB_ID @@ -51,10 +42,10 @@ data class MergedHeaderItem(private val isCollapsed: Boolean, override fun bind(holder: Holder) { super.bind(holder) holder.expandView.setOnClickListener { - onCollapsedStateChanged(!isCollapsed) + attributes.onCollapsedStateChanged(!attributes.isCollapsed) } - if (isCollapsed) { - val summary = holder.expandView.resources.getQuantityString(R.plurals.membership_changes, mergeData.size, mergeData.size) + if (attributes.isCollapsed) { + val summary = holder.expandView.resources.getQuantityString(R.plurals.membership_changes, attributes.mergeData.size, attributes.mergeData.size) holder.summaryView.text = summary holder.summaryView.visibility = View.VISIBLE holder.avatarListView.visibility = View.VISIBLE @@ -62,7 +53,7 @@ data class MergedHeaderItem(private val isCollapsed: Boolean, val data = distinctMergeData.getOrNull(index) if (data != null && view is ImageView) { view.visibility = View.VISIBLE - avatarRenderer.render(data.avatarUrl, data.userId, data.memberName, view) + attributes.avatarRenderer.render(data.avatarUrl, data.userId, data.memberName, view) } else { view.visibility = View.GONE } @@ -84,6 +75,13 @@ data class MergedHeaderItem(private val isCollapsed: Boolean, val avatarUrl: String? ) + data class Attributes( + val isCollapsed: Boolean, + val mergeData: List, + val avatarRenderer: AvatarRenderer, + val onCollapsedStateChanged: (Boolean) -> Unit + ) + class Holder : BaseHolder(STUB_ID) { val expandView by bind(R.id.itemMergedExpandTextView) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt index aad090db05..568347e83e 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt @@ -32,47 +32,38 @@ import im.vector.riotx.features.home.room.detail.timeline.TimelineEventControlle abstract class NoticeItem : BaseEventItem() { @EpoxyAttribute - lateinit var avatarRenderer: AvatarRenderer - - @EpoxyAttribute - var noticeText: CharSequence? = null - - @EpoxyAttribute - lateinit var informationData: MessageInformationData - - @EpoxyAttribute - var baseCallback: TimelineEventController.BaseCallback? = null + lateinit var attributes: Attributes private var longClickListener = View.OnLongClickListener { - return@OnLongClickListener baseCallback?.onEventLongClicked(informationData, null, it) == true + return@OnLongClickListener attributes.callback?.onEventLongClicked(attributes.informationData, null, it) == true } @EpoxyAttribute var readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null private val _readReceiptsClickListener = DebouncedClickListener(View.OnClickListener { - readReceiptsCallback?.onReadReceiptsClicked(informationData.readReceipts) + readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts) }) private val _readMarkerCallback = object : ReadMarkerView.Callback { override fun onReadMarkerDisplayed() { - readReceiptsCallback?.onReadMarkerLongDisplayed(informationData) + readReceiptsCallback?.onReadMarkerLongDisplayed(attributes.informationData) } } override fun bind(holder: Holder) { super.bind(holder) - holder.noticeTextView.text = noticeText - avatarRenderer.render( - informationData.avatarUrl, - informationData.senderId, - informationData.memberName?.toString() - ?: informationData.senderId, + holder.noticeTextView.text = attributes.noticeText + attributes.avatarRenderer.render( + attributes.informationData.avatarUrl, + attributes.informationData.senderId, + attributes.informationData.memberName?.toString() + ?: attributes.informationData.senderId, holder.avatarImageView ) holder.view.setOnLongClickListener(longClickListener) - holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener) - holder.readMarkerView.bindView(informationData, _readMarkerCallback) + holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) + holder.readMarkerView.bindView(attributes.informationData, _readMarkerCallback) } override fun unbind(holder: Holder) { @@ -89,6 +80,13 @@ abstract class NoticeItem : BaseEventItem() { val readMarkerView by bind(R.id.readMarkerView) } + data class Attributes( + val avatarRenderer: AvatarRenderer, + val informationData: MessageInformationData, + val noticeText: CharSequence, + val callback: TimelineEventController.BaseCallback? = null + ) + companion object { private const val STUB_ID = R.id.messageContentNoticeStub } From d4111d053d3063919b98906a7ac8e1f53c945436 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 12 Sep 2019 16:35:45 +0200 Subject: [PATCH 04/22] Read marker: only show banner until scrolled to read marker --- .../home/room/detail/RoomDetailViewModel.kt | 4 ++-- .../timeline/factory/EncryptedItemFactory.kt | 3 +++ .../detail/timeline/factory/NoticeItemFactory.kt | 6 +++++- .../home/room/detail/timeline/item/NoticeItem.kt | 16 +++++----------- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index bbdb7ab619..62c067159e 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -637,7 +637,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro .combineLatest( room.rx().liveRoomSummary(), visibleEventsObservable.distinctUntilChanged(), - isEventVisibleObservable { it.hasReadMarker }.startWith(false), + isEventVisibleObservable { it.hasReadMarker }.startWith(false).takeUntil { it }, Function3 { roomSummary, currentVisibleEvent, isReadMarkerViewVisible -> val readMarkerId = roomSummary.readMarkerId if (readMarkerId == null || isReadMarkerViewVisible || !timeline.isLive) { @@ -646,7 +646,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro val readMarkerPosition = timeline.getIndexOfEvent(readMarkerId) ?: Int.MAX_VALUE val currentVisibleEventPosition = timeline.getIndexOfEvent(currentVisibleEvent.event.root.eventId) - ?: Int.MIN_VALUE + ?: Int.MAX_VALUE readMarkerPosition > currentVisibleEventPosition } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt index 82ac4dc4d2..92f586ab7b 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt @@ -24,6 +24,7 @@ import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem_ import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.riotx.features.home.room.detail.timeline.helper.MessageItemAttributesFactory @@ -34,6 +35,7 @@ import javax.inject.Inject class EncryptedItemFactory @Inject constructor(private val messageInformationDataFactory: MessageInformationDataFactory, private val colorProvider: ColorProvider, private val stringProvider: StringProvider, + private val avatarSizeProvider: AvatarSizeProvider, private val attributesFactory: MessageItemAttributesFactory) { fun create(event: TimelineEvent, @@ -65,6 +67,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat val informationData = messageInformationDataFactory.create(event, nextEvent) val attributes = attributesFactory.create(null, informationData, callback) return MessageTextItem_() + .leftGuideline(avatarSizeProvider.leftGuideline) .highlighted(highlight) .attributes(attributes) .message(spannableStr) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt index 8663f87409..bb301cdcbd 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt @@ -16,6 +16,7 @@ package im.vector.riotx.features.home.room.detail.timeline.factory +import android.view.View import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController @@ -42,7 +43,10 @@ class NoticeItemFactory @Inject constructor( avatarRenderer = avatarRenderer, informationData = informationData, noticeText = formattedText, - callback = callback + itemLongClickListener = View.OnLongClickListener { view -> + callback?.onEventLongClicked(informationData, null, view) ?: false + }, + readReceiptsCallback = callback ) return NoticeItem_() .leftGuideline(avatarSizeProvider.leftGuideline) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt index 568347e83e..b6585ba6f8 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt @@ -34,20 +34,13 @@ abstract class NoticeItem : BaseEventItem() { @EpoxyAttribute lateinit var attributes: Attributes - private var longClickListener = View.OnLongClickListener { - return@OnLongClickListener attributes.callback?.onEventLongClicked(attributes.informationData, null, it) == true - } - - @EpoxyAttribute - var readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null - private val _readReceiptsClickListener = DebouncedClickListener(View.OnClickListener { - readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts) + attributes.readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts) }) private val _readMarkerCallback = object : ReadMarkerView.Callback { override fun onReadMarkerDisplayed() { - readReceiptsCallback?.onReadMarkerLongDisplayed(attributes.informationData) + attributes.readReceiptsCallback?.onReadMarkerLongDisplayed(attributes.informationData) } } @@ -61,7 +54,7 @@ abstract class NoticeItem : BaseEventItem() { ?: attributes.informationData.senderId, holder.avatarImageView ) - holder.view.setOnLongClickListener(longClickListener) + holder.view.setOnLongClickListener(attributes.itemLongClickListener) holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) holder.readMarkerView.bindView(attributes.informationData, _readMarkerCallback) } @@ -84,7 +77,8 @@ abstract class NoticeItem : BaseEventItem() { val avatarRenderer: AvatarRenderer, val informationData: MessageInformationData, val noticeText: CharSequence, - val callback: TimelineEventController.BaseCallback? = null + val itemLongClickListener: View.OnLongClickListener? = null, + val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null ) companion object { From 5d6d0202a9ad61a9e3ddf4efe5486e85ce9a8c32 Mon Sep 17 00:00:00 2001 From: ganfra Date: Sat, 14 Sep 2019 14:11:41 +0200 Subject: [PATCH 05/22] Timeline: try to fix some issues with permalink [WIP] --- .../session/room/timeline/DefaultTimeline.kt | 75 ++++++++++--------- .../matrix/android/internal/util/Handler.kt | 4 +- .../im/vector/riotx/core/utils/Debouncer.kt | 44 +++++++++++ .../im/vector/riotx/core/utils/Handler.kt | 31 ++++++++ .../home/createdirect/KnownUsersController.kt | 7 +- .../home/room/detail/RoomDetailFragment.kt | 65 ++++++++-------- .../ScrollOnHighlightedEventCallback.kt | 6 +- .../room/detail/ScrollOnNewMessageCallback.kt | 8 +- .../timeline/TimelineEventController.kt | 31 ++++---- 9 files changed, 178 insertions(+), 93 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/core/utils/Debouncer.kt create mode 100644 vector/src/main/java/im/vector/riotx/core/utils/Handler.kt diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index f14df5ada2..dd11b22b64 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -42,10 +42,11 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlin.collections.ArrayList import kotlin.collections.HashMap +import kotlin.math.max +import kotlin.math.min private const val MIN_FETCHING_COUNT = 30 -private const val DISPLAY_INDEX_UNKNOWN = Int.MIN_VALUE internal class DefaultTimeline( private val roomId: String, @@ -85,8 +86,8 @@ internal class DefaultTimeline( private var roomEntity: RoomEntity? = null - private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN - private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN + private var prevDisplayIndex: Int? = null + private var nextDisplayIndex: Int? = null private val builtEvents = Collections.synchronizedList(ArrayList()) private val builtEventsIdMap = Collections.synchronizedMap(HashMap()) private val backwardsPaginationState = AtomicReference(PaginationState()) @@ -222,6 +223,7 @@ internal class DefaultTimeline( if (isStarted.compareAndSet(true, false)) { eventDecryptor.destroy() Timber.v("Dispose timeline for roomId: $roomId and eventId: $initialEventId") + BACKGROUND_HANDLER.removeCallbacksAndMessages(null) BACKGROUND_HANDLER.post { cancelableBag.cancel() roomEntity?.sendingTimelineEvents?.removeAllChangeListeners() @@ -303,11 +305,8 @@ internal class DefaultTimeline( private fun hasMoreInCache(direction: Timeline.Direction): Boolean { return Realm.getInstance(realmConfiguration).use { localRealm -> val timelineEventEntity = buildEventQuery(localRealm).findFirst(direction) - ?: return false + ?: return false if (direction == Timeline.Direction.FORWARDS) { - if (findCurrentChunk(localRealm)?.isLastForward == true) { - return false - } val firstEvent = builtEvents.firstOrNull() ?: return true firstEvent.displayIndex < timelineEventEntity.root!!.displayIndex } else { @@ -334,16 +333,17 @@ internal class DefaultTimeline( * This has to be called on TimelineThread as it access realm live results * @return true if createSnapshot should be posted */ - private fun paginateInternal(startDisplayIndex: Int, + private fun paginateInternal(startDisplayIndex: Int?, direction: Timeline.Direction, - count: Int): Boolean { + count: Int, + strict: Boolean = false): Boolean { updatePaginationState(direction) { it.copy(requestedCount = count, isPaginating = true) } - val builtCount = buildTimelineEvents(startDisplayIndex, direction, count.toLong()) + val builtCount = buildTimelineEvents(startDisplayIndex, direction, count.toLong(), strict) val shouldFetchMore = builtCount < count && !hasReachedEnd(direction) if (shouldFetchMore) { val newRequestedCount = count - builtCount updatePaginationState(direction) { it.copy(requestedCount = newRequestedCount) } - val fetchingCount = Math.max(MIN_FETCHING_COUNT, newRequestedCount) + val fetchingCount = max(MIN_FETCHING_COUNT, newRequestedCount) executePaginationTask(direction, fetchingCount) } else { updatePaginationState(direction) { it.copy(isPaginating = false, requestedCount = 0) } @@ -404,20 +404,19 @@ internal class DefaultTimeline( .findFirst() shouldFetchInitialEvent = initialEvent == null initialEvent?.root?.displayIndex - } ?: DISPLAY_INDEX_UNKNOWN - + } prevDisplayIndex = initialDisplayIndex nextDisplayIndex = initialDisplayIndex val currentInitialEventId = initialEventId if (currentInitialEventId != null && shouldFetchInitialEvent) { fetchEvent(currentInitialEventId) } else { - val count = Math.min(settings.initialSize, liveEvents.size) + val count = min(settings.initialSize, liveEvents.size) if (isLive) { - paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count) + paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count, strict = false) } else { - paginateInternal(initialDisplayIndex, Timeline.Direction.FORWARDS, count / 2) - paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count / 2) + paginateInternal(initialDisplayIndex, Timeline.Direction.FORWARDS, count / 2, strict = false) + paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count / 2, strict = true) } } postSnapshot() @@ -429,9 +428,9 @@ internal class DefaultTimeline( private fun executePaginationTask(direction: Timeline.Direction, limit: Int) { val token = getTokenLive(direction) ?: return val params = PaginationTask.Params(roomId = roomId, - from = token, - direction = direction.toPaginationDirection(), - limit = limit) + from = token, + direction = direction.toPaginationDirection(), + limit = limit) Timber.v("Should fetch $limit items $direction") cancelableBag += paginationTask @@ -479,14 +478,15 @@ internal class DefaultTimeline( * This has to be called on TimelineThread as it access realm live results * @return number of items who have been added */ - private fun buildTimelineEvents(startDisplayIndex: Int, + private fun buildTimelineEvents(startDisplayIndex: Int?, direction: Timeline.Direction, - count: Long): Int { - if (count < 1) { + count: Long, + strict: Boolean = false): Int { + if (count < 1 || startDisplayIndex == null) { return 0 } val start = System.currentTimeMillis() - val offsetResults = getOffsetResults(startDisplayIndex, direction, count) + val offsetResults = getOffsetResults(startDisplayIndex, direction, count, strict) if (offsetResults.isEmpty()) { return 0 } @@ -501,7 +501,7 @@ internal class DefaultTimeline( val timelineEvent = buildTimelineEvent(eventEntity) if (timelineEvent.isEncrypted() - && timelineEvent.root.mxDecryptionResult == null) { + && timelineEvent.root.mxDecryptionResult == null) { timelineEvent.root.eventId?.let { eventDecryptor.requestDecryption(it) } } @@ -527,16 +527,23 @@ internal class DefaultTimeline( */ private fun getOffsetResults(startDisplayIndex: Int, direction: Timeline.Direction, - count: Long): RealmResults { + count: Long, + strict: Boolean): RealmResults { val offsetQuery = liveEvents.where() if (direction == Timeline.Direction.BACKWARDS) { - offsetQuery - .sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.DESCENDING) - .lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, startDisplayIndex) + offsetQuery.sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.DESCENDING) + if (strict) { + offsetQuery.lessThan(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, startDisplayIndex) + } else { + offsetQuery.lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, startDisplayIndex) + } } else { - offsetQuery - .sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.ASCENDING) - .greaterThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, startDisplayIndex) + offsetQuery.sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.ASCENDING) + if (strict) { + offsetQuery.greaterThan(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, startDisplayIndex) + } else { + offsetQuery.greaterThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, startDisplayIndex) + } } return offsetQuery .limit(count) @@ -589,8 +596,8 @@ internal class DefaultTimeline( } private fun clearAllValues() { - prevDisplayIndex = DISPLAY_INDEX_UNKNOWN - nextDisplayIndex = DISPLAY_INDEX_UNKNOWN + prevDisplayIndex = null + nextDisplayIndex = null builtEvents.clear() builtEventsIdMap.clear() backwardsPaginationState.set(PaginationState()) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/Handler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/Handler.kt index 51fdbfe227..e723a908cc 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/Handler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/Handler.kt @@ -20,10 +20,10 @@ import android.os.Handler import android.os.HandlerThread import android.os.Looper -fun createBackgroundHandler(name: String): Handler = Handler( +internal fun createBackgroundHandler(name: String): Handler = Handler( HandlerThread(name).apply { start() }.looper ) -fun createUIHandler(): Handler = Handler( +internal fun createUIHandler(): Handler = Handler( Looper.getMainLooper() ) \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/core/utils/Debouncer.kt b/vector/src/main/java/im/vector/riotx/core/utils/Debouncer.kt new file mode 100644 index 0000000000..8c8bd1266f --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/utils/Debouncer.kt @@ -0,0 +1,44 @@ +/* + + * 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.core.utils + +import android.os.Handler + +internal class Debouncer(private val handler: Handler) { + + private val runnables = HashMap() + + fun debounce(identifier: String, millis: Long, r: Runnable): Boolean { + if (runnables.containsKey(identifier)) { + // debounce + val old = runnables[identifier] + handler.removeCallbacks(old) + } + insertRunnable(identifier, r, millis) + return true + } + + private fun insertRunnable(identifier: String, r: Runnable, millis: Long) { + val chained = Runnable { + handler.post(r) + runnables.remove(identifier) + } + runnables[identifier] = chained + handler.postDelayed(chained, millis) + } +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/core/utils/Handler.kt b/vector/src/main/java/im/vector/riotx/core/utils/Handler.kt new file mode 100644 index 0000000000..51316d7e2f --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/utils/Handler.kt @@ -0,0 +1,31 @@ +/* + + * 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.core.utils + +import android.os.Handler +import android.os.HandlerThread +import android.os.Looper + +internal fun createBackgroundHandler(name: String): Handler = Handler( + HandlerThread(name).apply { start() }.looper +) + +internal fun createUIHandler(): Handler = Handler( + Looper.getMainLooper() +) \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/createdirect/KnownUsersController.kt b/vector/src/main/java/im/vector/riotx/features/home/createdirect/KnownUsersController.kt index fbb1cfcc4e..87fd32a784 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/createdirect/KnownUsersController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/createdirect/KnownUsersController.kt @@ -19,22 +19,17 @@ package im.vector.riotx.features.home.createdirect import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.paging.PagedListEpoxyController import com.airbnb.mvrx.Async -import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Incomplete -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.user.model.User -import im.vector.matrix.android.internal.util.createUIHandler import im.vector.matrix.android.internal.util.firstLetterOfDisplayName import im.vector.riotx.R import im.vector.riotx.core.epoxy.EmptyItem_ -import im.vector.riotx.core.epoxy.errorWithRetryItem import im.vector.riotx.core.epoxy.loadingItem import im.vector.riotx.core.epoxy.noResultItem -import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.core.utils.createUIHandler import im.vector.riotx.features.home.AvatarRenderer import javax.inject.Inject diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index fd83a6f69e..61338e7858 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -202,6 +202,7 @@ class RoomDetailFragment : } } + private val roomDetailArgs: RoomDetailArgs by args() private val glideRequests by lazy { GlideApp.with(this) @@ -221,11 +222,13 @@ class RoomDetailFragment : @Inject lateinit var roomDetailViewModelFactory: RoomDetailViewModel.Factory @Inject lateinit var textComposerViewModelFactory: TextComposerViewModel.Factory @Inject lateinit var errorFormatter: ErrorFormatter - private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback - private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer @Inject lateinit var vectorPreferences: VectorPreferences + private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback + private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback + private lateinit var endlessScrollListener: EndlessRecyclerViewScrollListener + override fun getLayoutResId() = R.layout.fragment_room_detail @@ -374,17 +377,17 @@ class RoomDetailFragment : if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) { val parser = Parser.builder().build() val document = parser.parse(messageContent.formattedBody - ?: messageContent.body) + ?: messageContent.body) formattedBody = eventHtmlRenderer.render(document) } composerLayout.composerRelatedMessageContent.text = formattedBody - ?: nonFormattedBody + ?: nonFormattedBody composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "") composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) avatarRenderer.render(event.senderAvatar, event.root.senderId - ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) + ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length) composerLayout.expand { @@ -413,9 +416,9 @@ class RoomDetailFragment : REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data) REACTION_SELECT_REQUEST_CODE -> { val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID) - ?: return + ?: return val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT) - ?: return + ?: return //TODO check if already reacted with that? roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId)) } @@ -430,7 +433,10 @@ class RoomDetailFragment : epoxyVisibilityTracker.attach(recyclerView) layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true) val stateRestorer = LayoutManagerStateRestorer(layoutManager).register() - scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager) + endlessScrollListener = EndlessRecyclerViewScrollListener(layoutManager, RoomDetailViewModel.PAGINATION_COUNT) { direction -> + roomDetailViewModel.process(RoomDetailActions.LoadMoreTimelineEvents(direction)) + } + scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager, timelineEventController) scrollOnHighlightedEventCallback = ScrollOnHighlightedEventCallback(layoutManager, timelineEventController) recyclerView.layoutManager = layoutManager recyclerView.itemAnimator = null @@ -441,35 +447,32 @@ class RoomDetailFragment : it.dispatchTo(scrollOnHighlightedEventCallback) } - recyclerView.addOnScrollListener( - EndlessRecyclerViewScrollListener(layoutManager, RoomDetailViewModel.PAGINATION_COUNT) { direction -> - roomDetailViewModel.process(RoomDetailActions.LoadMoreTimelineEvents(direction)) - }) + recyclerView.addOnScrollListener(endlessScrollListener) recyclerView.setController(timelineEventController) timelineEventController.callback = this if (vectorPreferences.swipeToReplyIsEnabled()) { val swipeCallback = RoomMessageTouchHelperCallback(requireContext(), - R.drawable.ic_reply, - object : RoomMessageTouchHelperCallback.QuickReplayHandler { - override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { - (model as? AbsMessageItem)?.attributes?.informationData?.let { - val eventId = it.eventId - roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId)) - } - } + R.drawable.ic_reply, + object : RoomMessageTouchHelperCallback.QuickReplayHandler { + override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { + (model as? AbsMessageItem)?.attributes?.informationData?.let { + val eventId = it.eventId + roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId)) + } + } - override fun canSwipeModel(model: EpoxyModel<*>): Boolean { - return when (model) { - is MessageFileItem, - is MessageImageVideoItem, - is MessageTextItem -> { - return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED - } - else -> false - } - } - }) + override fun canSwipeModel(model: EpoxyModel<*>): Boolean { + return when (model) { + is MessageFileItem, + is MessageImageVideoItem, + is MessageTextItem -> { + return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED + } + else -> false + } + } + }) val touchHelper = ItemTouchHelper(swipeCallback) touchHelper.attachToRecyclerView(recyclerView) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt index cf483090f1..c272e611a0 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt @@ -19,6 +19,7 @@ package im.vector.riotx.features.home.room.detail import androidx.recyclerview.widget.LinearLayoutManager import im.vector.riotx.core.platform.DefaultListUpdateCallback import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import timber.log.Timber import java.util.concurrent.atomic.AtomicReference class ScrollOnHighlightedEventCallback(private val layoutManager: LinearLayoutManager, @@ -28,17 +29,16 @@ class ScrollOnHighlightedEventCallback(private val layoutManager: LinearLayoutMa override fun onChanged(position: Int, count: Int, tag: Any?) { val eventId = scheduledEventId.get() ?: return - val positionToScroll = timelineEventController.searchPositionOfEvent(eventId) - if (positionToScroll != null) { val firstVisibleItem = layoutManager.findFirstCompletelyVisibleItemPosition() val lastVisibleItem = layoutManager.findLastCompletelyVisibleItemPosition() // Do not scroll it item is already visible if (positionToScroll !in firstVisibleItem..lastVisibleItem) { + Timber.v("Scroll to $positionToScroll") // Note: Offset will be from the bottom, since the layoutManager is reversed - layoutManager.scrollToPosition(position) + layoutManager.scrollToPosition(positionToScroll) } scheduledEventId.set(null) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnNewMessageCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnNewMessageCallback.kt index 8d3a96d8df..f4cfe9eb5a 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnNewMessageCallback.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnNewMessageCallback.kt @@ -18,11 +18,15 @@ package im.vector.riotx.features.home.room.detail import androidx.recyclerview.widget.LinearLayoutManager import im.vector.riotx.core.platform.DefaultListUpdateCallback +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import timber.log.Timber -class ScrollOnNewMessageCallback(private val layoutManager: LinearLayoutManager) : DefaultListUpdateCallback { +class ScrollOnNewMessageCallback(private val layoutManager: LinearLayoutManager, + private val timelineEventController: TimelineEventController) : DefaultListUpdateCallback { override fun onInserted(position: Int, count: Int) { - if (position == 0 && layoutManager.findFirstVisibleItemPosition() == 0) { + Timber.v("On inserted $count count at position: $position") + if (position == 0 && layoutManager.findFirstVisibleItemPosition() == 0 && !timelineEventController.isLoadingForward()) { layoutManager.scrollToPosition(0) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index 9b9172a6f9..147666345e 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -35,12 +35,7 @@ import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.factory.MergedHeaderItemFactory 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.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.home.room.detail.timeline.item.* import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.VideoContentRenderer import org.threeten.bp.LocalDateTime @@ -91,8 +86,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec fun onUrlLongClicked(url: String): Boolean } + private var showingForwardLoader = false private val modelCache = arrayListOf() - private var currentSnapshot: List = emptyList() private var inSubmitList: Boolean = false private var timeline: Timeline? = null @@ -163,7 +158,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec synchronized(modelCache) { for (i in 0 until modelCache.size) { if (modelCache[i]?.eventId == eventIdToHighlight - || modelCache[i]?.eventId == this.eventIdToHighlight) { + || modelCache[i]?.eventId == this.eventIdToHighlight) { modelCache[i] = null } } @@ -182,17 +177,18 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } override fun buildModels() { - val loaderAdded = LoadingItem_() - .id("forward_loading_item") + val timestamp = System.currentTimeMillis() + showingForwardLoader = LoadingItem_() + .id("forward_loading_item_$timestamp") .addWhen(Timeline.Direction.FORWARDS) val timelineModels = getModels() add(timelineModels) // Avoid displaying two loaders if there is no elements between them - if (!loaderAdded || timelineModels.isNotEmpty()) { + if (!showingForwardLoader || timelineModels.isNotEmpty()) { LoadingItem_() - .id("backward_loading_item") + .id("backward_loading_item_$timestamp") .addWhen(Timeline.Direction.BACKWARDS) } } @@ -224,8 +220,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec // 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]?.mergedHeaderModel != null + || modelCache[position]?.formattedDayModel != null) { modelCache[position] = buildItemModels(position, currentSnapshot) } } @@ -255,7 +251,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec it.id(event.localId) it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) } - val mergedHeaderModel = mergedHeaderItemFactory.create(event, nextEvent, items, addDaySeparator, currentPosition, callback){ + val mergedHeaderModel = mergedHeaderItemFactory.create(event, nextEvent, items, addDaySeparator, currentPosition, callback) { requestModelBuild() } val daySeparatorItem = buildDaySeparatorItem(addDaySeparator, date) @@ -284,6 +280,9 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec fun searchPositionOfEvent(eventId: String): Int? = synchronized(modelCache) { // Search in the cache var realPosition = 0 + if (showingForwardLoader) { + realPosition++ + } for (i in 0 until modelCache.size) { val itemCache = modelCache[i] if (itemCache?.eventId == eventId) { @@ -319,6 +318,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec return modelCache.getOrNull(position - offsetValue)?.eventId } + fun isLoadingForward() = showingForwardLoader + private data class CacheItemData( val localId: Long, val eventId: String?, From 69fb7bdf95bdd284423015799a5cae48fd5a8b37 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 16 Sep 2019 18:14:41 +0200 Subject: [PATCH 06/22] Timeline\Read marker: continue fixing potential issues --- .../session/room/timeline/DefaultTimeline.kt | 50 ++++++++++++++----- .../timeline/TimelineHiddenReadReceipts.kt | 2 +- .../home/room/detail/RoomDetailViewModel.kt | 9 ++-- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index dd11b22b64..e50d25d195 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -27,15 +27,33 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineSettings import im.vector.matrix.android.api.util.CancelableBag import im.vector.matrix.android.internal.database.mapper.TimelineEventMapper import im.vector.matrix.android.internal.database.mapper.asDomain -import im.vector.matrix.android.internal.database.model.* -import im.vector.matrix.android.internal.database.query.* +import im.vector.matrix.android.internal.database.model.ChunkEntity +import im.vector.matrix.android.internal.database.model.ChunkEntityFields +import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity +import im.vector.matrix.android.internal.database.model.EventEntity +import im.vector.matrix.android.internal.database.model.EventEntityFields +import im.vector.matrix.android.internal.database.model.RoomEntity +import im.vector.matrix.android.internal.database.model.TimelineEventEntity +import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields +import im.vector.matrix.android.internal.database.query.FilterContent +import im.vector.matrix.android.internal.database.query.findAllInRoomWithSendStates +import im.vector.matrix.android.internal.database.query.findIncludingEvent +import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom +import im.vector.matrix.android.internal.database.query.where +import im.vector.matrix.android.internal.database.query.whereInRoom import im.vector.matrix.android.internal.task.TaskConstraints import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.util.Debouncer import im.vector.matrix.android.internal.util.createBackgroundHandler import im.vector.matrix.android.internal.util.createUIHandler -import io.realm.* +import io.realm.OrderedCollectionChangeSet +import io.realm.OrderedRealmCollectionChangeListener +import io.realm.Realm +import io.realm.RealmConfiguration +import io.realm.RealmQuery +import io.realm.RealmResults +import io.realm.Sort import timber.log.Timber import java.util.* import java.util.concurrent.atomic.AtomicBoolean @@ -221,11 +239,11 @@ internal class DefaultTimeline( override fun dispose() { if (isStarted.compareAndSet(true, false)) { - eventDecryptor.destroy() + isReady.set(false) Timber.v("Dispose timeline for roomId: $roomId and eventId: $initialEventId") + cancelableBag.cancel() BACKGROUND_HANDLER.removeCallbacksAndMessages(null) BACKGROUND_HANDLER.post { - cancelableBag.cancel() roomEntity?.sendingTimelineEvents?.removeAllChangeListeners() eventRelations.removeAllChangeListeners() liveEvents.removeAllChangeListeners() @@ -238,6 +256,7 @@ internal class DefaultTimeline( it.close() } } + eventDecryptor.destroy() } } @@ -305,7 +324,7 @@ internal class DefaultTimeline( private fun hasMoreInCache(direction: Timeline.Direction): Boolean { return Realm.getInstance(realmConfiguration).use { localRealm -> val timelineEventEntity = buildEventQuery(localRealm).findFirst(direction) - ?: return false + ?: return false if (direction == Timeline.Direction.FORWARDS) { val firstEvent = builtEvents.firstOrNull() ?: return true firstEvent.displayIndex < timelineEventEntity.root!!.displayIndex @@ -426,11 +445,15 @@ internal class DefaultTimeline( * This has to be called on TimelineThread as it access realm live results */ private fun executePaginationTask(direction: Timeline.Direction, limit: Int) { - val token = getTokenLive(direction) ?: return + val token = getTokenLive(direction) + if (token == null) { + updatePaginationState(direction) { it.copy(isPaginating = false, requestedCount = 0) } + return + } val params = PaginationTask.Params(roomId = roomId, - from = token, - direction = direction.toPaginationDirection(), - limit = limit) + from = token, + direction = direction.toPaginationDirection(), + limit = limit) Timber.v("Should fetch $limit items $direction") cancelableBag += paginationTask @@ -501,7 +524,7 @@ internal class DefaultTimeline( val timelineEvent = buildTimelineEvent(eventEntity) if (timelineEvent.isEncrypted() - && timelineEvent.root.mxDecryptionResult == null) { + && timelineEvent.root.mxDecryptionResult == null) { timelineEvent.root.eventId?.let { eventDecryptor.requestDecryption(it) } } @@ -584,11 +607,14 @@ internal class DefaultTimeline( private fun fetchEvent(eventId: String) { val params = GetContextOfEventTask.Params(roomId, eventId) - contextOfEventTask.configureWith(params).executeBy(taskExecutor) + cancelableBag += contextOfEventTask.configureWith(params).executeBy(taskExecutor) } private fun postSnapshot() { BACKGROUND_HANDLER.post { + if (isReady.get().not()) { + return@post + } val snapshot = createSnapshot() val runnable = Runnable { listener?.onUpdated(snapshot) } debouncer.debounce("post_snapshot", runnable, 50) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt index 5658210302..5408668576 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt @@ -56,7 +56,7 @@ internal class TimelineHiddenReadReceipts constructor(private val readReceiptsSu var hasChange = false // Deletion here means we don't have any readReceipts for the given hidden events changeSet.deletions.forEach { - val eventId = correctedReadReceiptsEventByIndex[it] + val eventId = correctedReadReceiptsEventByIndex.get(it, "") val timelineEvent = liveEvents.where() .equalTo(TimelineEventEntityFields.EVENT_ID, eventId) .findFirst() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index 62c067159e..a45ea55825 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -643,11 +643,10 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro if (readMarkerId == null || isReadMarkerViewVisible || !timeline.isLive) { false } else { - val readMarkerPosition = timeline.getIndexOfEvent(readMarkerId) - ?: Int.MAX_VALUE - val currentVisibleEventPosition = timeline.getIndexOfEvent(currentVisibleEvent.event.root.eventId) - ?: Int.MAX_VALUE - readMarkerPosition > currentVisibleEventPosition + val readMarkerPosition = timeline.getTimelineEventWithId(readMarkerId)?.displayIndex + ?: Int.MIN_VALUE + val currentVisibleEventPosition = currentVisibleEvent.event.displayIndex + readMarkerPosition < currentVisibleEventPosition } } ) From 3066d5f3039f7402768f6df629c38be7f016cacc Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 17 Sep 2019 19:38:05 +0200 Subject: [PATCH 07/22] Timeline\ReadMarker: continue fixing issues --- .../api/session/room/timeline/Timeline.kt | 2 +- .../session/room/read/SetReadMarkersTask.kt | 2 - .../session/room/timeline/DefaultTimeline.kt | 8 +- .../session/sync/RoomFullyReadHandler.kt | 12 +- .../riotx/core/extensions/TimelineEvent.kt | 4 + .../riotx/core/ui/views/ReadMarkerView.kt | 4 +- .../home/room/detail/RoomDetailFragment.kt | 121 +++++++++++++----- .../home/room/detail/RoomDetailViewModel.kt | 22 +++- .../timeline/TimelineEventController.kt | 24 +++- .../factory/MergedHeaderItemFactory.kt | 24 +++- .../EndlessRecyclerViewScrollListener.kt | 63 --------- .../helper/MessageInformationDataFactory.kt | 4 +- .../detail/timeline/item/AbsMessageItem.kt | 3 +- .../detail/timeline/item/BaseEventItem.kt | 2 + .../detail/timeline/item/MergedHeaderItem.kt | 1 + .../room/detail/timeline/item/NoticeItem.kt | 3 +- .../res/drawable-hdpi/arrow_up_circle.png | Bin 0 -> 686 bytes .../main/res/drawable-hdpi/chevron_down.png | Bin 0 -> 303 bytes .../res/drawable-mdpi/arrow_up_circle.png | Bin 0 -> 414 bytes .../main/res/drawable-mdpi/chevron_down.png | Bin 0 -> 231 bytes .../res/drawable-xhdpi/arrow_up_circle.png | Bin 0 -> 869 bytes .../main/res/drawable-xhdpi/chevron_down.png | Bin 0 -> 391 bytes .../res/drawable-xxhdpi/arrow_up_circle.png | Bin 0 -> 1332 bytes .../main/res/drawable-xxhdpi/chevron_down.png | Bin 0 -> 454 bytes .../res/drawable-xxxhdpi/arrow_up_circle.png | Bin 0 -> 1910 bytes .../res/drawable-xxxhdpi/chevron_down.png | Bin 0 -> 584 bytes .../main/res/layout/fragment_room_detail.xml | 13 ++ .../res/layout/view_jump_to_read_marker.xml | 5 +- 28 files changed, 181 insertions(+), 136 deletions(-) delete mode 100644 vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.kt create mode 100755 vector/src/main/res/drawable-hdpi/arrow_up_circle.png create mode 100755 vector/src/main/res/drawable-hdpi/chevron_down.png create mode 100755 vector/src/main/res/drawable-mdpi/arrow_up_circle.png create mode 100755 vector/src/main/res/drawable-mdpi/chevron_down.png create mode 100755 vector/src/main/res/drawable-xhdpi/arrow_up_circle.png create mode 100755 vector/src/main/res/drawable-xhdpi/chevron_down.png create mode 100755 vector/src/main/res/drawable-xxhdpi/arrow_up_circle.png create mode 100755 vector/src/main/res/drawable-xxhdpi/chevron_down.png create mode 100755 vector/src/main/res/drawable-xxxhdpi/arrow_up_circle.png create mode 100755 vector/src/main/res/drawable-xxxhdpi/chevron_down.png diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt index 3f90d3cd13..d0f4bff74b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt @@ -45,7 +45,7 @@ interface Timeline { fun dispose() - fun restartWithEventId(eventId: String) + fun restartWithEventId(eventId: String?) /** diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt index 9652faae81..26eb16b15c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt @@ -26,7 +26,6 @@ import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.query.find import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom -import im.vector.matrix.android.internal.database.query.getOrCreate import im.vector.matrix.android.internal.database.query.latestEvent import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.network.executeRequest @@ -36,7 +35,6 @@ import im.vector.matrix.android.internal.session.sync.RoomFullyReadHandler import im.vector.matrix.android.internal.task.Task import im.vector.matrix.android.internal.util.awaitTransaction import io.realm.Realm -import io.realm.RealmConfiguration import timber.log.Timber import javax.inject.Inject diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index e50d25d195..88c13cc056 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -114,7 +114,7 @@ internal class DefaultTimeline( private val timelineID = UUID.randomUUID().toString() override val isLive - get() = initialEventId == null + get() = !hasMoreToLoad(Timeline.Direction.FORWARDS) private val eventDecryptor = TimelineEventDecryptor(realmConfiguration, timelineID, cryptoService) @@ -260,7 +260,7 @@ internal class DefaultTimeline( } } - override fun restartWithEventId(eventId: String) { + override fun restartWithEventId(eventId: String?) { dispose() initialEventId = eventId start() @@ -415,7 +415,7 @@ internal class DefaultTimeline( */ private fun handleInitialLoad() { var shouldFetchInitialEvent = false - val initialDisplayIndex = if (isLive) { + val initialDisplayIndex = if (initialEventId == null) { liveEvents.firstOrNull()?.root?.displayIndex } else { val initialEvent = liveEvents.where() @@ -431,7 +431,7 @@ internal class DefaultTimeline( fetchEvent(currentInitialEventId) } else { val count = min(settings.initialSize, liveEvents.size) - if (isLive) { + if (initialEventId == null) { paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count, strict = false) } else { paginateInternal(initialDisplayIndex, Timeline.Direction.FORWARDS, count / 2, strict = false) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt index 9757d0f421..45fbe7329d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt @@ -17,6 +17,7 @@ package im.vector.matrix.android.internal.session.sync import im.vector.matrix.android.api.session.room.read.FullyReadContent +import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity @@ -37,12 +38,13 @@ internal class RoomFullyReadHandler @Inject constructor() { RoomSummaryEntity.getOrCreate(realm, roomId).apply { readMarkerId = content.eventId } - val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId).apply { - eventId = content.eventId - } - + val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId) // Remove the old marker if any - readMarkerEntity.timelineEvent?.firstOrNull()?.readMarker = null + if (readMarkerEntity.eventId.isNotEmpty()) { + val oldReadMarkerEvent = TimelineEventEntity.where(realm, eventId = readMarkerEntity.eventId).findFirst() + oldReadMarkerEvent?.readMarker = null + } + readMarkerEntity.eventId = content.eventId // Attach to timelineEvent if known val timelineEventEntity = TimelineEventEntity.where(realm, eventId = content.eventId).findFirst() timelineEventEntity?.readMarker = readMarkerEntity diff --git a/vector/src/main/java/im/vector/riotx/core/extensions/TimelineEvent.kt b/vector/src/main/java/im/vector/riotx/core/extensions/TimelineEvent.kt index 58fcd0b5cd..6c7a6be1fd 100644 --- a/vector/src/main/java/im/vector/riotx/core/extensions/TimelineEvent.kt +++ b/vector/src/main/java/im/vector/riotx/core/extensions/TimelineEvent.kt @@ -23,3 +23,7 @@ fun TimelineEvent.canReact(): Boolean { // Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment return root.getClearType() == EventType.MESSAGE && root.sendState.isSent() && !root.isRedacted() } + +fun TimelineEvent.displayReadMarker(myUserId: String): Boolean { + return hasReadMarker && readReceipts.find { it.user.userId == myUserId } == null +} diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt index becab54da3..f5f086ac8b 100644 --- a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt @@ -42,9 +42,9 @@ class ReadMarkerView @JvmOverloads constructor( private var callback: Callback? = null private var callbackDispatcherJob: Job? = null - fun bindView(informationData: MessageInformationData, readMarkerCallback: Callback) { + fun bindView(displayReadMarker: Boolean, readMarkerCallback: Callback) { this.callback = readMarkerCallback - if (informationData.displayReadMarker) { + if (displayReadMarker) { visibility = VISIBLE callbackDispatcherJob = GlobalScope.launch(Dispatchers.Main) { delay(DELAY_IN_MS) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 61338e7858..48cdea6e59 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -44,6 +44,7 @@ import androidx.core.content.ContextCompat import androidx.core.util.Pair import androidx.core.view.ViewCompat import androidx.core.view.forEach +import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProviders import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager @@ -57,6 +58,7 @@ import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Success import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState import com.github.piasy.biv.BigImageViewer import com.github.piasy.biv.loader.ImageLoader import com.google.android.material.snackbar.Snackbar @@ -78,6 +80,7 @@ import im.vector.matrix.android.api.session.room.model.message.MessageTextConten import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent import im.vector.matrix.android.api.session.room.send.SendState +import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent @@ -96,6 +99,7 @@ import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.core.ui.views.JumpToReadMarkerView import im.vector.riotx.core.ui.views.NotificationAreaView +import im.vector.riotx.core.utils.Debouncer import im.vector.riotx.core.utils.PERMISSIONS_FOR_TAKING_PHOTO import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_DOWNLOAD_FILE @@ -105,6 +109,7 @@ import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CA import im.vector.riotx.core.utils.allGranted import im.vector.riotx.core.utils.checkPermissions import im.vector.riotx.core.utils.copyToClipboard +import im.vector.riotx.core.utils.createUIHandler import im.vector.riotx.core.utils.openCamera import im.vector.riotx.core.utils.shareMedia import im.vector.riotx.core.utils.toast @@ -127,7 +132,6 @@ import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsB import im.vector.riotx.features.home.room.detail.timeline.action.SimpleAction import im.vector.riotx.features.home.room.detail.timeline.action.ViewEditHistoryBottomSheet import im.vector.riotx.features.home.room.detail.timeline.action.ViewReactionBottomSheet -import im.vector.riotx.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener import im.vector.riotx.features.home.room.detail.timeline.item.AbsMessageItem import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem import im.vector.riotx.features.home.room.detail.timeline.item.MessageImageVideoItem @@ -211,6 +215,8 @@ class RoomDetailFragment : private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel() private val textComposerViewModel: TextComposerViewModel by fragmentViewModel() + private val debouncer = Debouncer(createUIHandler()) + @Inject lateinit var session: Session @Inject lateinit var avatarRenderer: AvatarRenderer @Inject lateinit var timelineEventController: TimelineEventController @@ -227,7 +233,6 @@ class RoomDetailFragment : private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback - private lateinit var endlessScrollListener: EndlessRecyclerViewScrollListener override fun getLayoutResId() = R.layout.fragment_room_detail @@ -254,6 +259,7 @@ class RoomDetailFragment : setupInviteView() setupNotificationView() setupJumpToReadMarkerView() + setupJumpToBottomView() roomDetailViewModel.subscribe { renderState(it) } textComposerViewModel.subscribe { renderTextComposerState(it) } roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) } @@ -306,6 +312,21 @@ class RoomDetailFragment : } } + private fun setupJumpToBottomView() { + jumpToBottomView.isVisible = false + jumpToBottomView.setOnClickListener { + withState(roomDetailViewModel) { state -> + recyclerView.stopScroll() + if (state.timeline?.isLive == false) { + state.timeline.restartWithEventId(null) + } else { + layoutManager.scrollToPosition(0) + } + jumpToBottomView.isVisible = false + } + } + } + private fun setupJumpToReadMarkerView() { jumpToReadMarkerView.callback = this } @@ -377,17 +398,17 @@ class RoomDetailFragment : if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) { val parser = Parser.builder().build() val document = parser.parse(messageContent.formattedBody - ?: messageContent.body) + ?: messageContent.body) formattedBody = eventHtmlRenderer.render(document) } composerLayout.composerRelatedMessageContent.text = formattedBody - ?: nonFormattedBody + ?: nonFormattedBody composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "") composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) avatarRenderer.render(event.senderAvatar, event.root.senderId - ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) + ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length) composerLayout.expand { @@ -416,9 +437,9 @@ class RoomDetailFragment : REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data) REACTION_SELECT_REQUEST_CODE -> { val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID) - ?: return + ?: return val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT) - ?: return + ?: return //TODO check if already reacted with that? roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId)) } @@ -428,14 +449,12 @@ class RoomDetailFragment : // PRIVATE METHODS ***************************************************************************** + private fun setupRecyclerView() { val epoxyVisibilityTracker = EpoxyVisibilityTracker() epoxyVisibilityTracker.attach(recyclerView) layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true) val stateRestorer = LayoutManagerStateRestorer(layoutManager).register() - endlessScrollListener = EndlessRecyclerViewScrollListener(layoutManager, RoomDetailViewModel.PAGINATION_COUNT) { direction -> - roomDetailViewModel.process(RoomDetailActions.LoadMoreTimelineEvents(direction)) - } scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager, timelineEventController) scrollOnHighlightedEventCallback = ScrollOnHighlightedEventCallback(layoutManager, timelineEventController) recyclerView.layoutManager = layoutManager @@ -446,38 +465,67 @@ class RoomDetailFragment : it.dispatchTo(scrollOnNewMessageCallback) it.dispatchTo(scrollOnHighlightedEventCallback) } - - recyclerView.addOnScrollListener(endlessScrollListener) recyclerView.setController(timelineEventController) + recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { + + override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) { + updateJumpToBottomViewVisibility() + } + } + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + when (newState) { + RecyclerView.SCROLL_STATE_IDLE -> { + updateJumpToBottomViewVisibility() + } + RecyclerView.SCROLL_STATE_DRAGGING, + RecyclerView.SCROLL_STATE_SETTLING -> { + jumpToBottomView.hide() + } + } + } + }) timelineEventController.callback = this if (vectorPreferences.swipeToReplyIsEnabled()) { val swipeCallback = RoomMessageTouchHelperCallback(requireContext(), - R.drawable.ic_reply, - object : RoomMessageTouchHelperCallback.QuickReplayHandler { - override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { - (model as? AbsMessageItem)?.attributes?.informationData?.let { - val eventId = it.eventId - roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId)) - } - } + R.drawable.ic_reply, + object : RoomMessageTouchHelperCallback.QuickReplayHandler { + override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { + (model as? AbsMessageItem)?.attributes?.informationData?.let { + val eventId = it.eventId + roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId)) + } + } - override fun canSwipeModel(model: EpoxyModel<*>): Boolean { - return when (model) { - is MessageFileItem, - is MessageImageVideoItem, - is MessageTextItem -> { - return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED - } - else -> false - } - } - }) + override fun canSwipeModel(model: EpoxyModel<*>): Boolean { + return when (model) { + is MessageFileItem, + is MessageImageVideoItem, + is MessageTextItem -> { + return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED + } + else -> false + } + } + }) val touchHelper = ItemTouchHelper(swipeCallback) touchHelper.attachToRecyclerView(recyclerView) } } + private fun updateJumpToBottomViewVisibility() { + debouncer.debounce("jump_to_bottom_visibility", 100, Runnable { + Timber.v("First visible: ${layoutManager.findFirstCompletelyVisibleItemPosition()}") + if (layoutManager.findFirstCompletelyVisibleItemPosition() != 0) { + jumpToBottomView.show() + } else { + jumpToBottomView.hide() + } + }) + } + private fun setupComposer() { val elevation = 6f val backgroundDrawable = ColorDrawable(ThemeUtils.getColor(requireContext(), R.attr.riotx_background)) @@ -737,7 +785,7 @@ class RoomDetailFragment : .show() } - // TimelineEventController.Callback ************************************************************ +// TimelineEventController.Callback ************************************************************ override fun onUrlClicked(url: String): Boolean { return permalinkHandler.launch(requireActivity(), url, object : NavigateToRoomInterceptor { @@ -835,6 +883,10 @@ class RoomDetailFragment : vectorBaseActivity.notImplemented("open audio file") } + override fun onLoadMore(direction: Timeline.Direction) { + roomDetailViewModel.process(RoomDetailActions.LoadMoreTimelineEvents(direction)) + } + override fun onEventCellClicked(informationData: MessageInformationData, messageContent: MessageContent?, view: View) { } @@ -901,7 +953,7 @@ class RoomDetailFragment : } } - // AutocompleteUserPresenter.Callback +// AutocompleteUserPresenter.Callback override fun onQueryUsers(query: CharSequence?) { textComposerViewModel.process(TextComposerActions.QueryUsers(query)) @@ -1066,6 +1118,7 @@ class RoomDetailFragment : snack.show() } + // VectorInviteView.Callback override fun onAcceptInvite() { @@ -1078,7 +1131,7 @@ class RoomDetailFragment : roomDetailViewModel.process(RoomDetailActions.RejectInvite) } - // JumpToReadMarkerView.Callback +// JumpToReadMarkerView.Callback override fun onJumpToReadMarkerClicked(readMarkerId: String) { roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(readMarkerId, false)) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index a45ea55825..ee4b3c0423 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -21,6 +21,8 @@ import android.text.TextUtils import androidx.annotation.IdRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import arrow.core.Option +import arrow.core.getOrElse import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.Success @@ -635,16 +637,22 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro private fun observeJumpToReadMarkerViewVisibility() { Observable .combineLatest( - room.rx().liveRoomSummary(), + room.rx().liveRoomSummary().map { + val readMarkerId = it.readMarkerId + if (readMarkerId == null) { + Option.empty() + } else { + val timelineEvent = room.getTimeLineEvent(readMarkerId) + Option.fromNullable(timelineEvent) + } + }.distinctUntilChanged(), visibleEventsObservable.distinctUntilChanged(), - isEventVisibleObservable { it.hasReadMarker }.startWith(false).takeUntil { it }, - Function3 { roomSummary, currentVisibleEvent, isReadMarkerViewVisible -> - val readMarkerId = roomSummary.readMarkerId - if (readMarkerId == null || isReadMarkerViewVisible || !timeline.isLive) { + isEventVisibleObservable { it.hasReadMarker }.startWith(false), + Function3, RoomDetailActions.TimelineEventTurnsVisible, Boolean, Boolean> { readMarkerEvent, currentVisibleEvent, isReadMarkerViewVisible -> + if (readMarkerEvent.isEmpty() || isReadMarkerViewVisible) { false } else { - val readMarkerPosition = timeline.getTimelineEventWithId(readMarkerId)?.displayIndex - ?: Int.MIN_VALUE + val readMarkerPosition = readMarkerEvent.map { it.displayIndex }.getOrElse { Int.MIN_VALUE } val currentVisibleEventPosition = currentVisibleEvent.event.displayIndex readMarkerPosition < currentVisibleEventPosition } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index 147666345e..652f35fb67 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -24,6 +24,7 @@ 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.* import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent @@ -50,6 +51,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener { interface Callback : BaseCallback, ReactionPillCallback, AvatarCallback, UrlClickCallback, ReadReceiptsCallback { + fun onLoadMore(direction: Timeline.Direction) fun onEventInvisible(event: TimelineEvent) fun onEventVisible(event: TimelineEvent) fun onRoomCreateLinkClicked(url: String) @@ -158,7 +160,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec synchronized(modelCache) { for (i in 0 until modelCache.size) { if (modelCache[i]?.eventId == eventIdToHighlight - || modelCache[i]?.eventId == this.eventIdToHighlight) { + || modelCache[i]?.eventId == this.eventIdToHighlight) { modelCache[i] = null } } @@ -180,6 +182,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec val timestamp = System.currentTimeMillis() showingForwardLoader = LoadingItem_() .id("forward_loading_item_$timestamp") + .setVisibilityStateChangedListener(Timeline.Direction.FORWARDS) .addWhen(Timeline.Direction.FORWARDS) val timelineModels = getModels() @@ -189,6 +192,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec if (!showingForwardLoader || timelineModels.isNotEmpty()) { LoadingItem_() .id("backward_loading_item_$timestamp") + .setVisibilityStateChangedListener(Timeline.Direction.BACKWARDS) .addWhen(Timeline.Direction.BACKWARDS) } } @@ -220,8 +224,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec // 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]?.mergedHeaderModel != null + || modelCache[position]?.formattedDayModel != null) { modelCache[position] = buildItemModels(position, currentSnapshot) } } @@ -251,7 +255,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec it.id(event.localId) it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) } - val mergedHeaderModel = mergedHeaderItemFactory.create(event, nextEvent, items, addDaySeparator, currentPosition, callback) { + val mergedHeaderModel = mergedHeaderItemFactory.create(event, nextEvent, items, addDaySeparator, currentPosition, eventIdToHighlight, callback) { requestModelBuild() } val daySeparatorItem = buildDaySeparatorItem(addDaySeparator, date) @@ -277,6 +281,18 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec return shouldAdd } + /** + * Return true if added + */ + private fun LoadingItem_.setVisibilityStateChangedListener(direction: Timeline.Direction): LoadingItem_ { + return onVisibilityStateChanged { model, view, visibilityState -> + if (visibilityState == VisibilityState.VISIBLE) { + callback?.onLoadMore(direction) + } + } + } + + fun searchPositionOfEvent(eventId: String): Int? = synchronized(modelCache) { // Search in the cache var realPosition = 0 diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index 06514e5973..42f0688e50 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -17,6 +17,8 @@ package im.vector.riotx.features.home.room.detail.timeline.factory import im.vector.matrix.android.api.session.room.timeline.TimelineEvent +import im.vector.riotx.core.di.ActiveSessionHolder +import im.vector.riotx.core.extensions.displayReadMarker import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider @@ -29,7 +31,8 @@ import im.vector.riotx.features.home.room.detail.timeline.item.MergedHeaderItem import im.vector.riotx.features.home.room.detail.timeline.item.MergedHeaderItem_ import javax.inject.Inject -class MergedHeaderItemFactory @Inject constructor(private val avatarRenderer: AvatarRenderer, +class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: ActiveSessionHolder, + private val avatarRenderer: AvatarRenderer, private val avatarSizeProvider: AvatarSizeProvider) { private val collapsedEventIds = linkedSetOf() @@ -40,6 +43,7 @@ class MergedHeaderItemFactory @Inject constructor(private val avatarRenderer: Av items: List, addDaySeparator: Boolean, currentPosition: Int, + eventIdToHighlight: String?, callback: TimelineEventController.Callback?, requestModelBuild: () -> Unit) : MergedHeaderItem? { @@ -47,20 +51,30 @@ class MergedHeaderItemFactory @Inject constructor(private val avatarRenderer: Av return if (!event.canBeMerged() || (nextEvent?.root?.getClearType() == event.root.getClearType() && !addDaySeparator)) { null } else { + var highlighted = false + var showReadMarker = false val prevSameTypeEvents = items.prevSameTypeEvents(currentPosition, 2) if (prevSameTypeEvents.isEmpty()) { null } else { val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed() - val mergedData = mergedEvents.map { mergedEvent -> + val mergedData = ArrayList(mergedEvents.size) + mergedEvents.forEach { mergedEvent -> + if (!highlighted && mergedEvent.root.eventId == eventIdToHighlight) { + highlighted = true + } + if (!showReadMarker && mergedEvent.displayReadMarker(sessionHolder.getActiveSession().myUserId)) { + showReadMarker = true + } val senderAvatar = mergedEvent.senderAvatar() val senderName = mergedEvent.senderName() - MergedHeaderItem.Data( + val data = MergedHeaderItem.Data( userId = mergedEvent.root.senderId ?: "", avatarUrl = senderAvatar, memberName = senderName ?: "", eventId = mergedEvent.localId ) + mergedData.add(data) } val mergedEventIds = mergedEvents.map { it.localId } // We try to find if one of the item id were used as mergeItemCollapseStates key @@ -82,11 +96,13 @@ class MergedHeaderItemFactory @Inject constructor(private val avatarRenderer: Av onCollapsedStateChanged = { mergeItemCollapseStates[event.localId] = it requestModelBuild() - } + }, + showReadMarker = showReadMarker ) MergedHeaderItem_() .id(mergeId) .leftGuideline(avatarSizeProvider.leftGuideline) + .highlighted(highlighted) .attributes(attributes) .also { it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents)) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.kt deleted file mode 100644 index 9bcb7c634f..0000000000 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/EndlessRecyclerViewScrollListener.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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.helper - -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import im.vector.matrix.android.api.session.room.timeline.Timeline - -class EndlessRecyclerViewScrollListener(private val layoutManager: LinearLayoutManager, - private val visibleThreshold: Int, - private val onLoadMore: (Timeline.Direction) -> Unit -) : RecyclerView.OnScrollListener() { - - // The total number of items in the dataset after the last load - private var previousTotalItemCount = 0 - // True if we are still waiting for the last set of data to load. - private var loadingBackwards = true - private var loadingForwards = true - - // This happens many times a second during a scroll, so be wary of the code you place here. - // We are given a few useful parameters to help us work out if we need to load some more data, - // but first we check if we are waiting for the previous load to finish. - - override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { - val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() - val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() - val totalItemCount = layoutManager.itemCount - - // We check to see if the dataset count has - // changed, if so we conclude it has finished loading - if (totalItemCount != previousTotalItemCount) { - previousTotalItemCount = totalItemCount - loadingBackwards = false - loadingForwards = false - } - // If it isn’t currently loading, we check to see if we have reached - // the visibleThreshold and need to reload more data. - if (!loadingBackwards && lastVisibleItemPosition + visibleThreshold > totalItemCount) { - loadingBackwards = true - onLoadMore(Timeline.Direction.BACKWARDS) - } - if (!loadingForwards && firstVisibleItemPosition < visibleThreshold) { - loadingForwards = true - onLoadMore(Timeline.Direction.FORWARDS) - } - } - - -} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index 7ca5204766..b8a89a4669 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -27,6 +27,7 @@ import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.utils.isSingleEmoji import im.vector.riotx.features.home.getColorFromUserId import im.vector.riotx.core.date.VectorDateFormatter +import im.vector.riotx.core.extensions.displayReadMarker import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData import im.vector.riotx.features.home.room.detail.timeline.item.ReactionInfoData import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData @@ -64,8 +65,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId ?: "")) } - val displayReadMarker = event.hasReadMarker - && event.readReceipts.find { it.user.userId == session.myUserId } == null + val displayReadMarker = event.displayReadMarker(session.myUserId) return MessageInformationData( eventId = eventId, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt index b299d61540..44a5e2bdfb 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -106,7 +106,7 @@ abstract class AbsMessageItem : BaseEventItem() { holder.memberNameView.setOnLongClickListener(null) } holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) - holder.readMarkerView.bindView(attributes.informationData, _readMarkerCallback) + holder.readMarkerView.bindView(attributes.informationData.displayReadMarker, _readMarkerCallback) if (!shouldShowReactionAtBottom() || attributes.informationData.orderedReactionList.isNullOrEmpty()) { holder.reactionWrapper?.isVisible = false @@ -162,7 +162,6 @@ abstract class AbsMessageItem : BaseEventItem() { val avatarImageView by bind(R.id.messageAvatarImageView) val memberNameView by bind(R.id.messageMemberNameView) val timeView by bind(R.id.messageTimeView) - val readMarkerView by bind(R.id.readMarkerView) var reactionWrapper: ViewGroup? = null var reactionFlowHelper: Flow? = null } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt index 532d56f580..a97ec23c97 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt @@ -24,6 +24,7 @@ import im.vector.riotx.R import im.vector.riotx.core.epoxy.VectorEpoxyHolder import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.platform.CheckableView +import im.vector.riotx.core.ui.views.ReadMarkerView import im.vector.riotx.core.ui.views.ReadReceiptsView import im.vector.riotx.core.utils.DimensionUtils.dpToPx import org.w3c.dom.Attr @@ -49,6 +50,7 @@ abstract class BaseEventItem : VectorEpoxyModel val leftGuideline by bind(R.id.messageStartGuideline) val checkableBackground by bind(R.id.messageSelectedBackground) val readReceiptsView by bind(R.id.readReceiptsView) + val readMarkerView by bind(R.id.readMarkerView) override fun bindView(itemView: View) { super.bindView(itemView) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt index 0ac068c379..f07575e1a5 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt @@ -79,6 +79,7 @@ abstract class MergedHeaderItem : BaseEventItem() { data class Attributes( val isCollapsed: Boolean, + val showReadMarker: Boolean, val mergeData: List, val avatarRenderer: AvatarRenderer, val onCollapsedStateChanged: (Boolean) -> Unit diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt index d3443cb0fb..8e61a3be1f 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt @@ -55,7 +55,7 @@ abstract class NoticeItem : BaseEventItem() { ) holder.view.setOnLongClickListener(attributes.itemLongClickListener) holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) - holder.readMarkerView.bindView(attributes.informationData, _readMarkerCallback) + holder.readMarkerView.bindView(attributes.informationData.displayReadMarker, _readMarkerCallback) } override fun unbind(holder: Holder) { @@ -68,7 +68,6 @@ abstract class NoticeItem : BaseEventItem() { class Holder : BaseHolder(STUB_ID) { val avatarImageView by bind(R.id.itemNoticeAvatarView) val noticeTextView by bind(R.id.itemNoticeTextView) - val readMarkerView by bind(R.id.readMarkerView) } data class Attributes( diff --git a/vector/src/main/res/drawable-hdpi/arrow_up_circle.png b/vector/src/main/res/drawable-hdpi/arrow_up_circle.png new file mode 100755 index 0000000000000000000000000000000000000000..c7fba081c151926a44f8b955da6857a2c9a7e6f7 GIT binary patch literal 686 zcmV;f0#W^mP)Px%Xh}ptR7efImcL2^K@i40u?z}EEJR645yiq+5L+P@K7obcGx!ERfTfMCwu(g_ zKr1bUfQq0fhzM$9{C$@hZ|~eoOoV*!?d<&7nZ4a*ZSKLb9)fhAh58)RK@qlsxPfSV=p?Hm({8Lc^7vME?Jki;O zi?IZ=Lvx%uF;w6O6n1kX0tH_UwqS@BO1FYK#e(uzMraz+3}wf= zhQDPoS{A`&i+;2q_C@Guj52v$LBbUt6+rPpE8;^=0ue2->X zQZe0i!fi)nEeWR4ny?O)cpKh8K~j2HQ1M0FnPEo7s;1~-En*!*9j1kt>3$esdRS2L zMLe0II!4Efc+oKU2xS?bk+^fBLN9$4WCb4Ru0q8ZF=d8l71M7*nCpnFC6Q*elUd?D zn@DsrjF`1i_gY5Z@M&fkJ&o}ay{(qd_wiE2PT{8wZS+pjSHy-ze;`$d-Ot}36REMJ zAy#`NKvb5SCapGU3o!ClKH#Q|96<8kiX}Px#=}AOER5%f(l*U*pi^`R4u}PiI57YxMnD{x2c#?kV_@>94 zf-h#ewhLTye7VAulU{ zq}U|zYv6qzmb`@`T6mrsL_3kDbw9Ee&p|l(`UGE`(4IFjC?Eg;002ovPDHLkV1g32 BdZPdU literal 0 HcmV?d00001 diff --git a/vector/src/main/res/drawable-mdpi/arrow_up_circle.png b/vector/src/main/res/drawable-mdpi/arrow_up_circle.png new file mode 100755 index 0000000000000000000000000000000000000000..aad94e9a4e8492b15a30423dd8965ea33cf2eff4 GIT binary patch literal 414 zcmV;P0b%}$P)Px$SV=@dR5%f>l)XyBU=)TML8uPhN{3#Flel#DCfY>@ucEuVV`uRS3S9)DH{jx+ zb)lYC!NbQ(~WWuNddJcPq2dBiT@RZ$er5o%mp9m+`drKzsWgE@LQ0S)j#N^vXAdrX+23xzg5)ox>(3y*-F}kPx#p-DtRR45f=)H5*N%E-XL&&0?Tc>et1*9=4eLql89fB(O&|IfhikqM;v|NsBi zK!N=r2}1gzn)ieBfGq~u%*e=i6)2!h$PgsW+8{k(y&xO0NI0T<2bUV4DTDF(p%{S85Re!y%^*1p13-evP6jK21Q?3VAaM}DY5+(=&&o=S h@!QXJAU3`<0s!RilQx$3s?Pub002ovPDHLkV1lR@TOPx&A4x<(R9Fekn7eKhK@^6wQEE!0q_UMVHHc8e6(S8ygp?*UNzX&@47>md4?qzq za!I5hA-{lyWZ{ZPAaSXJP>A{dWlr|&usiEZVtl0EXXaf0-LYq9oh?lvqtR%Ew0och zR>2yOd;;fS2zptTU7~MCL;~!9L+}+eiDDG199_W8NhsP!Pbt3REfe+VB&!k727Z~oGk=s! zn!u@AKs8O&OW)IK!FgtpXkE>Xk+F`d8ET_)IWu2LhhCA@^-;Rz-}_wIf?gQboDMaPbTr*8SB?Xo_;|;fr-YGfBJ;!L*-*A|N%?*1FmFb(mdihz4r*BhhZ=$^GMIB2ixiRCU`@nfWr>eXv!H*2 z+8tPY@W*7XT)rPSl2R|Ao+g`m-n7&x$bjd}XPd;DEGsRnSE>@u7m!LWs6BEw@!S|RT0wD*~B}|X8I)q ze`of&!dK=r9c(cv<<^Psxh6uBimtwrj1{UNWv)i8&8WmMk)ciR8R*jReIhP*i4@D+ zXYv>vCE`mfBtkj^n|P<$a!msrs$P_g4U<5;iM;Q5t|c3~y6aq3@@Ma2XC8)LI$fF! zMW_e91&iiS!gwA^z0^}84!vb=t6o)(WeM`s26NE9*Ri-5yEm@+Hn(!DXHm$xaJhc$ z%>NEUk8S5EIN+S+__0C$AYj8aNDhgwKYl2afcOaV{|;z&DUe#6BW$=!i*?8kq&nQG zdA$a0+3F-Ugcj#T(U?L?cgnw`G0jEC&RJijYhMsEBT%Jj+{V5be>Ql65Yv{>2h)mj vi?<42eA+Pu=kkYDa>?q*iyXdN`AhE)_8o00001b5ch_0Itp) z=>Px$K}keGR7efAl|6F8Fc3y}p9qAIaDkr{xJJSiZWZv6@PTIvxJdZI!I15KIkEue?W5z>?MTlh z0EksWa{p($8yt0ywjnxfVcViIK0Q6XF0Px(?ny*JRA>d=n$K@cQ545#RMY}pS+F90MErq3HO0r}r{F&U^2^H#4oyNxr?k_nz}T=lg!Y`=(y& zku{sm0jRfvEnov!4`iRgCom1B>h=0B+OAu@TZ{-<3)eAl6dVD&!5WZfb4*Tx26zG< zBlH{X@*9ELU0B@@#_15pWE*GI3K)F1-RgNuq!=!V{vyTiOuS8lQNV$M=dRFe7 zgW&}5E);JV-&vu)jzW|ZD7Q7$s4I1`;i&u6jAfv&t+wQ%V#UfGYM@zUs(ztGrj76fhc65<56Klb2n?wA!@?#PNd7>hJWpNPb@ynX7kb2== zFgho#2mOgSU$!MSMN~}LiMWYK($=0-y{kgedY!lS&<>>ZB?`Zh6yM-85@Je({BiYa z{Hq0GPkHjIM@*vvK~*cg=SZYi9&0aJ^)NaBzMI*1vDnm>_$*Pn#9Q;mjUw|t80PZCi95x-T8AWTofGYC z1;?#+$r&sZ6=Mp-dE7Cyz4dBcCt=;RWKl6`fw<^ax|OgmPL!6&QtS#&^TF{e?U}s8 zFN?5EoH%6S6nBbwwSHbr!>rk8mbB07Kv@6AItyTGt zOG^~?nDXq^aX-7d@{4A#<%Zh!n>^AWEem^$V^W_-A85eXRnxE1{pkdGV~$(ykXPIN zpyh8MTOeyONB5P=2Jc=l529)Hc5h`u0*Oyw%uyl&dbRv^4sN|S3FkTy73xf1ehUHp zxnLT^_1_YA5Z0d`C4#vA`-~)EGEVaT`|!6QttGDiKGlZ3mhy4^aNH2xYB^eTpYks0 z47cnle+N6tCjyYQ=UQ3#qnE^vAHT#AxM(s>wR9$`eTzW0qJF?qZ!f;+f5i3QPmZE! z#I1-QiN$(Lw+QU7pY_=-fIz(!jDi`^sfj~e|9*0{i3|Fr&apf$iutd8I@>j2Ry0D} qf9yQOd|XP`0Px$fJsC_R9Fekm^)6yFc5~vLBJ7Khz?LFx1e3YEOzAbEH&yOdIXcj_Y*vMk$wd3}4LnzBsCo6-XwIh_U2 z*+>Enb>Rt*v|a*8;4Hxa&m!O?!357HU?sr_&njRf!3@tXFeniK9{1b9jx(yN`n@<` w6d&)OyXqTpqP8#f1M2ZKJ-#l>lKpSr9|{bpV53r)BLDyZ07*qoM6N<$f{+fpK>z>% literal 0 HcmV?d00001 diff --git a/vector/src/main/res/drawable-xxxhdpi/arrow_up_circle.png b/vector/src/main/res/drawable-xxxhdpi/arrow_up_circle.png new file mode 100755 index 0000000000000000000000000000000000000000..61fd2b1e48dd4a03e94839c014b2de8fa0176a87 GIT binary patch literal 1910 zcmV-+2Z{KJP)Px+FiAu~RCodHoXu-pMHt3&rLD9W!4|SHHQ0q(m#u|T$fiV)NLStH#-(lwVg+}F zKyjyFn?g(6g)06DMX}U{BJ^Wf{2-B1>!O>Akyw*L{5>}_m)_iY=giDK_apVb@Z{V( z^UnM6oO5Q*nVFl$*nnkYV`C>Fj6ViNwBpfdG}cuxC~5T5F=R~P?>IOFUUt~e(TZb( zy1U?Khg%%4LfRdU+g~800j9x4umtK7*^*7!w#Xe`0ttEo%z92wp)^lJ2;&0!uhA* zR6!6L#;*fEI{nUPKeWr7ANF;-_|fi&D_4DvTtN9lM!vP2MqTMb&H-=}b$yXxkaD)Jq4#QCepBtpCy?(dQ4aNzgh5@-Raed< zbWW!x)GIy302E71luJFg8-~;&SM@o6qVq$#YDO3aivUXJRh@8-FM)*GV8r>rPy4I988%niUf0SnkF|ZbasZ89G|pe z1XWj3-V-+l^==h=ebD_qbhni!W$RI~f>@uVT+`p(P}a+yB^n`yrbFIJhV@-BgV^YH z)@s{Uj*&?adn+IKP~H}B#uxI^BY-8!x%$BtCC#Z=axEjCWL2E)1Gj+Au%OTTk_}`i zVmJ0Mc`j;-#uGpjPa#>Ou=XcpY>tD_bC$~VR$r?q2cTR+qTKD7Nh&pUY03FoBgGhw zM9z2m;45ZOVmTu-h#;KD63M!l0&aEzv6Q z*i$ZFEeBijja89aP6X;?uYhBzZ6bQIamCq;s3ld!a%*zp3DR15NVL*$BUF)ERx>%? zZCxZOH{}?TCMTXCEvi|+}k{)|HbGYw5vK~C47QQ{M#zaPA8Ql`OX!s>V`y|jEkUWLba~U>cApR z#y+M}m%b&*a`qoi$xhM@bXolP1odjn-wI5eEX)>F@6WpsqO`qoV2W3+}HxM}P)ixAeLE23k8p)ow0<_9G!>iQ2gR}QJ zYFX$v%4EM*!R8P6cnPB~f(LwmQ0Ty#=TgioJ21SCBW<;PA>g2RX~zXz8gw9(&LVs$ ziRI3ktP<-j;;y*yPtw-IlGQjXf+1J7a%^%^MbHthF5QMtq`2I)wJ#)X1Zf9*NYumE zvrQyqeeh+<%^Jm;ocJr~Y9!XzBX!%oDwbQ5lO{oQMt8{6>g?X$?kq&&w{lx{oU~>X zWy6|XJIKBI#NB2tr-KH0U;A=@i|P9pzA$inH&C|Si{6VQ@MT9GjP`Zm&^rT(KL!cF#a#2npS#(@<*1>GwH!mm( z{9X=RLYF%u?7jp)<(n5=u!GqCq8HrHz#k6!oc-dNLLYX^x4$@obbo3yktL^70vSdf z66$^fl_P1i1uu4nVS2QQGe(fRibmUj6G)o3IwDv8|J_LLuAS1s@mi}-}*DJg(G#`H{0A-f6<0X?Fl>zvKIo>#7)|I(bWFfx1Mt`tmQjibt`M6GBo`v^x&8TA|TN&<&*@fA!T!T)8b7aR2}S07*qoM6N<$f;Z-&p8x;= literal 0 HcmV?d00001 diff --git a/vector/src/main/res/drawable-xxxhdpi/chevron_down.png b/vector/src/main/res/drawable-xxxhdpi/chevron_down.png new file mode 100755 index 0000000000000000000000000000000000000000..31b1eb464ad2c87757a9a00c66b152a57548b87a GIT binary patch literal 584 zcmV-O0=NB%P)Px%0!c(cRA>d=nNLo_Kop1HD+w+!Bu0%JPXG%R#-%IWdKgdPHN1fvS-CWt5Ca=e zK!_Wa#1#pS{gpq0ly;``X4+vApzTcGyzeVa%M4Ro+U@fb#gki7bb)Gz+8 z$$sE(S$v#S(qurSnyZ}5^rdQiHR$!8SC5Cb-RgACc&Z+hqW$&U`Gcw6ijph;4;~G1 z0#B)n0P81)vp0jM?k0Rf*aUmD{Mx5*DWw{rO8A3UjTE%mq4*~sZLOSFgeXA^e$E$R zz}qDWCh%a7Fxq3l+XV?G@L°j*d(duASoEWr$($6y9KFQCl>&}CI_kv6$61we#N zpoK`^pSC%#2?$MsEj)NP1cV|%0)DH2s!Nc9&lFHq36k)+0;(oK8eU64RV288*A`H= z1Xu7z0?Lx$65d#VZwap93kmQg!3TU{0j?$Zg0~XjN`g;#YXQ;{e8Yajs)NE$SHSq7>%R~ zR+8`_vt=#vQ~DuCC-0h0Bgr=JqtWo_@br%O=QzFz9mKD1V!mhEyB@s0yh-+@6YCcf WVtT2XUdo;T0000 + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/view_jump_to_read_marker.xml b/vector/src/main/res/layout/view_jump_to_read_marker.xml index 35e14a649d..4ded65e8f8 100644 --- a/vector/src/main/res/layout/view_jump_to_read_marker.xml +++ b/vector/src/main/res/layout/view_jump_to_read_marker.xml @@ -11,11 +11,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="16dp" - android:layout_marginLeft="16dp" android:layout_toStartOf="@+id/closeJumpToReadMarkerView" - android:layout_toLeftOf="@+id/closeJumpToReadMarkerView" - android:drawableStart="@drawable/jump_to_unread" - android:drawableLeft="@drawable/jump_to_unread" + android:drawableStart="@drawable/arrow_up_circle" android:drawablePadding="10dp" android:gravity="center_vertical" android:paddingTop="12dp" From 88fb9667a3e51cb90031ae8aa2ded9167974b7f7 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 18 Sep 2019 20:21:42 +0200 Subject: [PATCH 08/22] Timeline: continue fixing issues + read marker --- .../main/java/im/vector/matrix/rx/RxRoom.kt | 13 +++- .../matrix/android/api/session/room/Room.kt | 2 +- .../api/session/room/read/ReadService.kt | 17 ++++- .../session/room/timeline/TimelineService.kt | 2 +- .../matrix/android/api/util/Optional.kt | 40 ++++++++++ .../internal/session/room/DefaultRoom.kt | 2 +- .../session/room/read/DefaultReadService.kt | 22 ++++-- .../session/room/read/FullyReadContent.kt | 2 +- .../session/room/read/SetReadMarkersTask.kt | 73 +++++++++++------- .../room/timeline/DefaultTimelineService.kt | 2 +- .../session/sync/RoomFullyReadHandler.kt | 3 +- .../internal/session/sync/RoomSyncHandler.kt | 2 +- .../api/pushrules/PushrulesConditionTest.kt | 4 +- .../riotx/core/ui/views/ReadMarkerView.kt | 25 ++++--- .../im/vector/riotx/core/utils/Debouncer.kt | 6 ++ .../home/room/detail/RoomDetailFragment.kt | 74 ++++++++++--------- .../home/room/detail/RoomDetailViewModel.kt | 70 ++++++++++++------ .../home/room/detail/RoomDetailViewState.kt | 3 +- .../room/detail/ScrollOnNewMessageCallback.kt | 2 +- .../timeline/TimelineEventController.kt | 24 +++--- .../timeline/factory/DefaultItemFactory.kt | 3 +- .../timeline/factory/EncryptedItemFactory.kt | 3 +- .../factory/MergedHeaderItemFactory.kt | 16 ++-- .../timeline/factory/MessageItemFactory.kt | 5 +- .../timeline/factory/NoticeItemFactory.kt | 4 +- .../timeline/factory/TimelineItemFactory.kt | 13 ++-- .../helper/MessageInformationDataFactory.kt | 5 +- .../detail/timeline/item/AbsMessageItem.kt | 12 ++- .../detail/timeline/item/MergedHeaderItem.kt | 21 ++++++ .../timeline/item/MessageInformationData.kt | 1 + .../room/detail/timeline/item/NoticeItem.kt | 13 +++- 31 files changed, 331 insertions(+), 153 deletions(-) create mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/Optional.kt rename matrix-sdk-android/src/main/java/im/vector/matrix/android/{api => internal}/session/room/read/FullyReadContent.kt (92%) diff --git a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt index 0ff0987dfe..c8cc430c65 100644 --- a/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt +++ b/matrix-sdk-android-rx/src/main/java/im/vector/matrix/rx/RxRoom.kt @@ -21,13 +21,14 @@ import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary import im.vector.matrix.android.api.session.room.model.ReadReceipt import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.timeline.TimelineEvent +import im.vector.matrix.android.api.util.Optional import io.reactivex.Observable import io.reactivex.Single class RxRoom(private val room: Room) { fun liveRoomSummary(): Observable { - return room.liveRoomSummary().asObservable() + return room.getRoomSummaryLive().asObservable() } fun liveRoomMemberIds(): Observable> { @@ -39,7 +40,15 @@ class RxRoom(private val room: Room) { } fun liveTimelineEvent(eventId: String): Observable { - return room.liveTimeLineEvent(eventId).asObservable() + return room.getTimeLineEventLive(eventId).asObservable() + } + + fun liveReadMarker(): Observable> { + return room.getReadMarkerLive().asObservable() + } + + fun liveReadReceipt(): Observable> { + return room.getMyReadReceiptLive().asObservable() } fun loadRoomMembersIfNeeded(): Single = Single.create { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt index ec6b382f8f..9a4e0131d8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt @@ -47,7 +47,7 @@ interface Room : * A live [RoomSummary] associated with the room * You can observe this summary to get dynamic data from this room. */ - fun liveRoomSummary(): LiveData + fun getRoomSummaryLive(): LiveData fun roomSummary(): RoomSummary? diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/ReadService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/ReadService.kt index 0ff0298b44..e315224880 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/ReadService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/ReadService.kt @@ -19,6 +19,7 @@ package im.vector.matrix.android.api.session.room.read import androidx.lifecycle.LiveData import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.room.model.ReadReceipt +import im.vector.matrix.android.api.util.Optional /** * This interface defines methods to handle read receipts and read marker in a room. It's implemented at the room level. @@ -40,12 +41,24 @@ interface ReadService { */ fun setReadMarker(fullyReadEventId: String, callback: MatrixCallback) + /** + * Check if an event is already read, ie. your read receipt is set on a more recent event. + */ fun isEventRead(eventId: String): Boolean /** - * Returns a nullable read marker for the room. + * Returns a live read marker id for the room. */ - fun getReadMarkerLive(): LiveData + fun getReadMarkerLive(): LiveData> + /** + * Returns a live read receipt id for the room. + */ + fun getMyReadReceiptLive(): LiveData> + + /** + * Returns a live list of read receipts for a given event + * @param eventId: the event + */ fun getEventReadReceiptsLive(eventId: String): LiveData> } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt index fdf99bd22c..b55bc17946 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/TimelineService.kt @@ -36,5 +36,5 @@ interface TimelineService { fun getTimeLineEvent(eventId: String): TimelineEvent? - fun liveTimeLineEvent(eventId: String): LiveData + fun getTimeLineEventLive(eventId: String): LiveData } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/Optional.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/Optional.kt new file mode 100644 index 0000000000..abe2d23993 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/util/Optional.kt @@ -0,0 +1,40 @@ +/* + + * 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.matrix.android.api.util + +data class Optional constructor(private val value: T?) { + + fun get(): T { + return value!! + } + + fun getOrNull(): T? { + return value + } + + fun getOrElse(fn: () -> T): T { + return value ?: fn() + } + + companion object { + fun from(value: T?): Optional { + return Optional(value) + } + } + +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt index 492dd03543..10262ccebd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt @@ -53,7 +53,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String, RelationService by relationService, MembershipService by roomMembersService { - override fun liveRoomSummary(): LiveData { + override fun getRoomSummaryLive(): LiveData { val liveRealmData = RealmLiveData(monarchy.realmConfiguration) { realm -> RoomSummaryEntity.where(realm, roomId).isNotEmpty(RoomSummaryEntityFields.DISPLAY_NAME) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt index 3709521cc3..1f0bae6fea 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt @@ -25,6 +25,7 @@ import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.session.room.model.ReadReceipt import im.vector.matrix.android.api.session.room.read.ReadService +import im.vector.matrix.android.api.util.Optional import im.vector.matrix.android.internal.database.RealmLiveData import im.vector.matrix.android.internal.database.mapper.ReadReceiptsSummaryMapper import im.vector.matrix.android.internal.database.model.ChunkEntity @@ -83,24 +84,33 @@ internal class DefaultReadService @AssistedInject constructor(@Assisted private var isEventRead = false monarchy.doWithRealm { val readReceipt = ReadReceiptEntity.where(it, roomId, credentials.userId).findFirst() - ?: return@doWithRealm + ?: return@doWithRealm val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(it, roomId) - ?: return@doWithRealm + ?: return@doWithRealm val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex - ?: Int.MIN_VALUE + ?: Int.MIN_VALUE val eventToCheckIndex = liveChunk.timelineEvents.find(eventId)?.root?.displayIndex - ?: Int.MAX_VALUE + ?: Int.MAX_VALUE isEventRead = eventToCheckIndex <= readReceiptIndex } return isEventRead } - override fun getReadMarkerLive(): LiveData { + override fun getReadMarkerLive(): LiveData> { val liveRealmData = RealmLiveData(monarchy.realmConfiguration) { realm -> ReadMarkerEntity.where(realm, roomId) } return Transformations.map(liveRealmData) { results -> - results.firstOrNull()?.eventId + Optional.from(results.firstOrNull()?.eventId) + } + } + + override fun getMyReadReceiptLive(): LiveData> { + val liveRealmData = RealmLiveData(monarchy.realmConfiguration) { realm -> + ReadReceiptEntity.where(realm, roomId = roomId, userId = credentials.userId) + } + return Transformations.map(liveRealmData) { results -> + Optional.from(results.firstOrNull()?.eventId) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/FullyReadContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/FullyReadContent.kt similarity index 92% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/FullyReadContent.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/FullyReadContent.kt index a73b9ef5b7..6790ea658c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/FullyReadContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/FullyReadContent.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package im.vector.matrix.android.api.session.room.read +package im.vector.matrix.android.internal.session.room.read import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt index 26eb16b15c..beaf4eb0af 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt @@ -18,7 +18,6 @@ package im.vector.matrix.android.internal.session.room.read import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.auth.data.Credentials -import im.vector.matrix.android.api.session.room.read.FullyReadContent import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntity @@ -76,29 +75,49 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI if (fullyReadEventId != null && isReadMarkerMoreRecent(params.roomId, fullyReadEventId)) { if (LocalEchoEventFactory.isLocalEchoId(fullyReadEventId)) { - Timber.w("Can't set read marker for local event ${params.fullyReadEventId}") + Timber.w("Can't set read marker for local event $fullyReadEventId") } else { - updateReadMarker(params.roomId, fullyReadEventId) markers[READ_MARKER] = fullyReadEventId } } + if (readReceiptEventId != null - && !isEventRead(params.roomId, readReceiptEventId)) { + && !isEventRead(params.roomId, readReceiptEventId)) { if (LocalEchoEventFactory.isLocalEchoId(readReceiptEventId)) { - Timber.w("Can't set read receipt for local event ${params.fullyReadEventId}") + Timber.w("Can't set read receipt for local event $readReceiptEventId") } else { - updateNotificationCountIfNecessary(params.roomId, readReceiptEventId) markers[READ_RECEIPT] = readReceiptEventId } } if (markers.isEmpty()) { return } + updateDatabase(params.roomId, markers) executeRequest { apiCall = roomAPI.sendReadMarker(params.roomId, markers) } } + private suspend fun updateDatabase(roomId: String, markers: HashMap) { + monarchy.awaitTransaction { realm -> + val readMarkerId = markers[READ_MARKER] + val readReceiptId = markers[READ_RECEIPT] + + if (readMarkerId != null) { + roomFullyReadHandler.handle(realm, roomId, FullyReadContent(readMarkerId)) + } + if (readReceiptId != null) { + val isLatestReceived = TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId == readReceiptId + if (isLatestReceived) { + val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst() + ?: return@awaitTransaction + roomSummary.notificationCount = 0 + roomSummary.highlightCount = 0 + } + } + } + } + private fun isReadMarkerMoreRecent(roomId: String, fullyReadEventId: String): Boolean { return Realm.getInstance(monarchy.realmConfiguration).use { realm -> @@ -111,36 +130,36 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI } } - private suspend fun updateReadMarker(roomId: String, eventId: String) { - monarchy.awaitTransaction { realm -> - roomFullyReadHandler.handle(realm, roomId, FullyReadContent(eventId)) - } - } - - private suspend fun updateNotificationCountIfNecessary(roomId: String, eventId: String) { - monarchy.awaitTransaction { realm -> - val isLatestReceived = TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId == eventId - if (isLatestReceived) { - val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst() - ?: return@awaitTransaction - roomSummary.notificationCount = 0 - roomSummary.highlightCount = 0 - } - } - } private fun isEventRead(roomId: String, eventId: String): Boolean { return Realm.getInstance(monarchy.realmConfiguration).use { realm -> val readReceipt = ReadReceiptEntity.where(realm, roomId, credentials.userId).findFirst() - ?: return false + ?: return false val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) - ?: return false + ?: return false val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex - ?: Int.MIN_VALUE + ?: Int.MIN_VALUE val eventToCheckIndex = liveChunk.timelineEvents.find(eventId)?.root?.displayIndex - ?: Int.MAX_VALUE + ?: Int.MAX_VALUE eventToCheckIndex <= readReceiptIndex } } + private fun SetReadMarkersTask.Params.fullyReadEventId(): String? { + if (fullyReadEventId != null) { + return this.fullyReadEventId + } else { + Realm.getInstance(monarchy.realmConfiguration).use { realm -> + val readReceipt = ReadReceiptEntity.where(realm, roomId, credentials.userId).findFirst() + val readMarker = ReadMarkerEntity.where(realm, roomId).findFirst() + return if (readMarker?.eventId == readReceipt?.eventId) { + readReceiptEventId + } else { + null + } + } + } + } + + } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt index 59d37a8062..0ded458a20 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt @@ -73,7 +73,7 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv }) } - override fun liveTimeLineEvent(eventId: String): LiveData { + override fun getTimeLineEventLive(eventId: String): LiveData { val liveData = RealmLiveData(monarchy.realmConfiguration) { TimelineEventEntity.where(it, eventId = eventId) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt index 45fbe7329d..99fbc5750d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt @@ -16,8 +16,7 @@ package im.vector.matrix.android.internal.session.sync -import im.vector.matrix.android.api.session.room.read.FullyReadContent -import im.vector.matrix.android.internal.database.model.EventEntity +import im.vector.matrix.android.internal.session.room.read.FullyReadContent import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt index 344833cca7..906963d83a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt @@ -23,7 +23,7 @@ import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.tag.RoomTagContent -import im.vector.matrix.android.api.session.room.read.FullyReadContent +import im.vector.matrix.android.internal.session.room.read.FullyReadContent import im.vector.matrix.android.internal.database.helper.add import im.vector.matrix.android.internal.database.helper.addOrUpdate import im.vector.matrix.android.internal.database.helper.addStateEvent diff --git a/matrix-sdk-android/src/test/java/im/vector/matrix/android/api/pushrules/PushrulesConditionTest.kt b/matrix-sdk-android/src/test/java/im/vector/matrix/android/api/pushrules/PushrulesConditionTest.kt index 3d4df602b7..2c518fa6ee 100644 --- a/matrix-sdk-android/src/test/java/im/vector/matrix/android/api/pushrules/PushrulesConditionTest.kt +++ b/matrix-sdk-android/src/test/java/im/vector/matrix/android/api/pushrules/PushrulesConditionTest.kt @@ -184,7 +184,7 @@ class PushrulesConditionTest { } class MockRoom(override val roomId: String, val _numberOfJoinedMembers: Int) : Room { - override fun liveTimeLineEvent(eventId: String): LiveData { + override fun getTimeLineEventLive(eventId: String): LiveData { TODO("not implemented") //To change body of created functions use File | Settings | File Templates. } @@ -193,7 +193,7 @@ class PushrulesConditionTest { return _numberOfJoinedMembers } - override fun liveRoomSummary(): LiveData { + override fun getRoomSummaryLive(): LiveData { TODO("not implemented") //To change body of created functions use File | Settings | File Templates. } diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt index f5f086ac8b..986acef616 100644 --- a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt @@ -23,8 +23,8 @@ import android.util.AttributeSet import android.view.View import android.view.animation.Animation import android.view.animation.AnimationUtils +import androidx.core.view.isInvisible import im.vector.riotx.R -import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData import kotlinx.coroutines.* private const val DELAY_IN_MS = 1_500L @@ -36,30 +36,34 @@ class ReadMarkerView @JvmOverloads constructor( ) : View(context, attrs, defStyleAttr) { interface Callback { - fun onReadMarkerDisplayed() + fun onReadMarkerLongBound() } + private var eventId: String? = null private var callback: Callback? = null private var callbackDispatcherJob: Job? = null - fun bindView(displayReadMarker: Boolean, readMarkerCallback: Callback) { + fun bindView(eventId: String?, hasReadMarker: Boolean, displayReadMarker: Boolean, readMarkerCallback: Callback) { + this.eventId = eventId this.callback = readMarkerCallback if (displayReadMarker) { - visibility = VISIBLE - callbackDispatcherJob = GlobalScope.launch(Dispatchers.Main) { - delay(DELAY_IN_MS) - callback?.onReadMarkerDisplayed() - } startAnimation() } else { - visibility = INVISIBLE + this.animation?.cancel() + this.visibility = INVISIBLE + } + if (hasReadMarker) { + callbackDispatcherJob = GlobalScope.launch(Dispatchers.Main) { + delay(DELAY_IN_MS) + callback?.onReadMarkerLongBound() + } } - } fun unbind() { this.callbackDispatcherJob?.cancel() this.callback = null + this.eventId = null this.animation?.cancel() this.visibility = INVISIBLE } @@ -80,6 +84,7 @@ class ReadMarkerView @JvmOverloads constructor( override fun onAnimationRepeat(animation: Animation) {} }) } + visibility = VISIBLE animation.start() } diff --git a/vector/src/main/java/im/vector/riotx/core/utils/Debouncer.kt b/vector/src/main/java/im/vector/riotx/core/utils/Debouncer.kt index 8c8bd1266f..5001449c3f 100644 --- a/vector/src/main/java/im/vector/riotx/core/utils/Debouncer.kt +++ b/vector/src/main/java/im/vector/riotx/core/utils/Debouncer.kt @@ -33,6 +33,10 @@ internal class Debouncer(private val handler: Handler) { return true } + fun cancelAll() { + handler.removeCallbacksAndMessages(null) + } + private fun insertRunnable(identifier: String, r: Runnable, millis: Long) { val chained = Runnable { handler.post(r) @@ -41,4 +45,6 @@ internal class Debouncer(private val handler: Handler) { runnables[identifier] = chained handler.postDelayed(chained, millis) } + + } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 48cdea6e59..0bb45ccfb8 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -44,7 +44,6 @@ import androidx.core.content.ContextCompat import androidx.core.util.Pair import androidx.core.view.ViewCompat import androidx.core.view.forEach -import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProviders import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager @@ -312,17 +311,21 @@ class RoomDetailFragment : } } + override fun onDestroy() { + debouncer.cancelAll() + super.onDestroy() + } + private fun setupJumpToBottomView() { - jumpToBottomView.isVisible = false + jumpToBottomView.visibility = View.INVISIBLE jumpToBottomView.setOnClickListener { + jumpToBottomView.visibility = View.INVISIBLE withState(roomDetailViewModel) { state -> - recyclerView.stopScroll() if (state.timeline?.isLive == false) { state.timeline.restartWithEventId(null) } else { layoutManager.scrollToPosition(0) } - jumpToBottomView.isVisible = false } } } @@ -398,17 +401,17 @@ class RoomDetailFragment : if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) { val parser = Parser.builder().build() val document = parser.parse(messageContent.formattedBody - ?: messageContent.body) + ?: messageContent.body) formattedBody = eventHtmlRenderer.render(document) } composerLayout.composerRelatedMessageContent.text = formattedBody - ?: nonFormattedBody + ?: nonFormattedBody composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "") composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) avatarRenderer.render(event.senderAvatar, event.root.senderId - ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) + ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length) composerLayout.expand { @@ -437,9 +440,9 @@ class RoomDetailFragment : REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data) REACTION_SELECT_REQUEST_CODE -> { val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID) - ?: return + ?: return val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT) - ?: return + ?: return //TODO check if already reacted with that? roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId)) } @@ -490,33 +493,33 @@ class RoomDetailFragment : if (vectorPreferences.swipeToReplyIsEnabled()) { val swipeCallback = RoomMessageTouchHelperCallback(requireContext(), - R.drawable.ic_reply, - object : RoomMessageTouchHelperCallback.QuickReplayHandler { - override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { - (model as? AbsMessageItem)?.attributes?.informationData?.let { - val eventId = it.eventId - roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId)) - } - } + R.drawable.ic_reply, + object : RoomMessageTouchHelperCallback.QuickReplayHandler { + override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { + (model as? AbsMessageItem)?.attributes?.informationData?.let { + val eventId = it.eventId + roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId)) + } + } - override fun canSwipeModel(model: EpoxyModel<*>): Boolean { - return when (model) { - is MessageFileItem, - is MessageImageVideoItem, - is MessageTextItem -> { - return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED - } - else -> false - } - } - }) + override fun canSwipeModel(model: EpoxyModel<*>): Boolean { + return when (model) { + is MessageFileItem, + is MessageImageVideoItem, + is MessageTextItem -> { + return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED + } + else -> false + } + } + }) val touchHelper = ItemTouchHelper(swipeCallback) touchHelper.attachToRecyclerView(recyclerView) } } private fun updateJumpToBottomViewVisibility() { - debouncer.debounce("jump_to_bottom_visibility", 100, Runnable { + debouncer.debounce("jump_to_bottom_visibility", 250, Runnable { Timber.v("First visible: ${layoutManager.findFirstCompletelyVisibleItemPosition()}") if (layoutManager.findFirstCompletelyVisibleItemPosition() != 0) { jumpToBottomView.show() @@ -684,7 +687,7 @@ class RoomDetailFragment : val summary = state.asyncRoomSummary() val inviter = state.asyncInviter() if (summary?.membership == Membership.JOIN) { - timelineEventController.setTimeline(state.timeline, state.highlightedEventId) + timelineEventController.update(state.timeline, state.highlightedEventId, state.hideReadMarker) inviteView.visibility = View.GONE val uid = session.myUserId val meMember = session.getRoom(state.roomId)?.getRoomMember(uid) @@ -747,7 +750,6 @@ class RoomDetailFragment : } } - private fun renderSendMessageResult(sendMessageResult: SendMessageResult) { when (sendMessageResult) { is SendMessageResult.MessageSent -> { @@ -945,15 +947,15 @@ class RoomDetailFragment : .show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS") } - override fun onReadMarkerLongDisplayed(informationData: MessageInformationData) { + override fun onReadMarkerLongDisplayed() = withState(roomDetailViewModel) { state -> val firstVisibleItem = layoutManager.findFirstVisibleItemPosition() - val eventId = timelineEventController.searchEventIdAtPosition(firstVisibleItem) - if (eventId != null) { - roomDetailViewModel.process(RoomDetailActions.SetReadMarkerAction(eventId)) + val nextReadMarkerId = timelineEventController.searchEventIdAtPosition(firstVisibleItem) + if (nextReadMarkerId != null) { + roomDetailViewModel.process(RoomDetailActions.SetReadMarkerAction(nextReadMarkerId)) } } -// AutocompleteUserPresenter.Callback + // AutocompleteUserPresenter.Callback override fun onQueryUsers(query: CharSequence?) { textComposerViewModel.process(TextComposerActions.QueryUsers(query)) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index ee4b3c0423..afe4ea6681 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -40,13 +40,13 @@ import im.vector.matrix.android.api.session.events.model.isTextMessage import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.file.FileService import im.vector.matrix.android.api.session.room.model.Membership -import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.getFileUrl import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineSettings +import im.vector.matrix.android.api.util.Optional import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.matrix.rx.rx @@ -62,6 +62,7 @@ import im.vector.riotx.features.command.ParsedCommand import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDisplayableEvents import im.vector.riotx.features.settings.VectorPreferences import io.reactivex.Observable +import io.reactivex.functions.BiFunction import io.reactivex.functions.Function3 import io.reactivex.rxkotlin.subscribeBy import org.commonmark.parser.Parser @@ -116,6 +117,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro observeEventDisplayedActions() observeSummaryState() observeJumpToReadMarkerViewVisibility() + observeReadMarkerVisibility() room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear() timeline.start() setState { copy(timeline = this@RoomDetailViewModel.timeline) } @@ -156,7 +158,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) { val tombstoneContent = action.event.getClearContent().toModel() - ?: return + ?: return val roomId = tombstoneContent.replacementRoom ?: "" val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN @@ -303,7 +305,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro //is original event a reply? val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel()?.relatesTo?.inReplyTo?.eventId - ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId + ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId if (inReplyTo != null) { //TODO check if same content? room.getTimeLineEvent(inReplyTo)?.let { @@ -312,12 +314,12 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } else { val messageContent: MessageContent? = state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() - ?: state.sendMode.timelineEvent.root.getClearContent().toModel() + ?: state.sendMode.timelineEvent.root.getClearContent().toModel() val existingBody = messageContent?.body ?: "" if (existingBody != action.text) { room.editTextMessage(state.sendMode.timelineEvent.root.eventId - ?: "", messageContent?.type - ?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown) + ?: "", messageContent?.type + ?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown) } else { Timber.w("Same message content, do not send edition") } @@ -332,7 +334,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro is SendMode.QUOTE -> { val messageContent: MessageContent? = state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() - ?: state.sendMode.timelineEvent.root.getClearContent().toModel() + ?: state.sendMode.timelineEvent.root.getClearContent().toModel() val textMsg = messageContent?.body val finalText = legacyRiotQuoteText(textMsg, action.text) @@ -635,29 +637,30 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } private fun observeJumpToReadMarkerViewVisibility() { - Observable - .combineLatest( - room.rx().liveRoomSummary().map { + Observable.combineLatest( + room.rx().liveRoomSummary() + .map { val readMarkerId = it.readMarkerId if (readMarkerId == null) { Option.empty() } else { - val timelineEvent = room.getTimeLineEvent(readMarkerId) - Option.fromNullable(timelineEvent) - } - }.distinctUntilChanged(), - visibleEventsObservable.distinctUntilChanged(), - isEventVisibleObservable { it.hasReadMarker }.startWith(false), - Function3, RoomDetailActions.TimelineEventTurnsVisible, Boolean, Boolean> { readMarkerEvent, currentVisibleEvent, isReadMarkerViewVisible -> - if (readMarkerEvent.isEmpty() || isReadMarkerViewVisible) { - false - } else { - val readMarkerPosition = readMarkerEvent.map { it.displayIndex }.getOrElse { Int.MIN_VALUE } - val currentVisibleEventPosition = currentVisibleEvent.event.displayIndex - readMarkerPosition < currentVisibleEventPosition + val readMarkerIndex = room.getTimeLineEvent(readMarkerId)?.displayIndex + ?: Int.MIN_VALUE + Option.just(readMarkerIndex) } } - ) + .distinctUntilChanged(), + visibleEventsObservable.distinctUntilChanged(), + isEventVisibleObservable { it.hasReadMarker }.startWith(false).takeUntil { it }, + Function3, RoomDetailActions.TimelineEventTurnsVisible, Boolean, Boolean> { readMarkerIndex, currentVisibleEvent, isReadMarkerViewVisible -> + if (readMarkerIndex.isEmpty() || isReadMarkerViewVisible) { + false + } else { + val currentVisibleEventPosition = currentVisibleEvent.event.displayIndex + readMarkerIndex.getOrElse { Int.MIN_VALUE } < currentVisibleEventPosition + } + } + ) .distinctUntilChanged() .subscribe { setState { copy(showJumpToReadMarker = it) } @@ -682,6 +685,25 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } } + private fun observeReadMarkerVisibility() { + Observable + .combineLatest( + room.rx().liveReadMarker(), + room.rx().liveReadReceipt(), + BiFunction, Optional, Boolean> { readMarker, readReceipt -> + readMarker.getOrNull() == readReceipt.getOrNull() + } + ) + .throttleLast(250, TimeUnit.MILLISECONDS) + .distinctUntilChanged() + .startWith(false) + .subscribe { + setState { copy(hideReadMarker = it) } + } + .disposeOnClear() + } + + private fun observeSummaryState() { asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary -> if (summary.membership == Membership.INVITE) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt index 5e36cf42dc..bf11740fc0 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt @@ -53,7 +53,8 @@ data class RoomDetailViewState( val tombstoneEventHandling: Async = Uninitialized, val syncState: SyncState = SyncState.IDLE, val showJumpToReadMarker: Boolean = false, - val highlightedEventId: String? = null + val highlightedEventId: String? = null, + val hideReadMarker: Boolean = false ) : MvRxState { constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnNewMessageCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnNewMessageCallback.kt index f4cfe9eb5a..998428477b 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnNewMessageCallback.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnNewMessageCallback.kt @@ -26,7 +26,7 @@ class ScrollOnNewMessageCallback(private val layoutManager: LinearLayoutManager, override fun onInserted(position: Int, count: Int) { Timber.v("On inserted $count count at position: $position") - if (position == 0 && layoutManager.findFirstVisibleItemPosition() == 0 && !timelineEventController.isLoadingForward()) { + if (position == 0 && layoutManager.findFirstCompletelyVisibleItemPosition() == 0 && !timelineEventController.isLoadingForward()) { layoutManager.scrollToPosition(0) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index 652f35fb67..b5a5fe8ca8 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -31,8 +31,6 @@ 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.MergedHeaderItemFactory import im.vector.riotx.features.home.room.detail.timeline.factory.TimelineItemFactory import im.vector.riotx.features.home.room.detail.timeline.helper.* @@ -80,7 +78,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec interface ReadReceiptsCallback { fun onReadReceiptsClicked(readReceipts: List) - fun onReadMarkerLongDisplayed(informationData: MessageInformationData) + fun onReadMarkerLongDisplayed() } interface UrlClickCallback { @@ -142,7 +140,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec requestModelBuild() } - fun setTimeline(timeline: Timeline?, eventIdToHighlight: String?) { + fun update(timeline: Timeline?, eventIdToHighlight: String?, hideReadMarker: Boolean) { if (this.timeline != timeline) { this.timeline = timeline this.timeline?.listener = this @@ -155,22 +153,30 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } + var requestModelBuild = false 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]?.eventId == this.eventIdToHighlight) { modelCache[i] = null } } } this.eventIdToHighlight = eventIdToHighlight - + requestModelBuild = true + } + if (this.hideReadMarker != hideReadMarker) { + this.hideReadMarker = hideReadMarker + requestModelBuild = true + } + if (requestModelBuild) { requestModelBuild() } } + private var hideReadMarker: Boolean = false private var eventIdToHighlight: String? = null override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { @@ -224,8 +230,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec // 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]?.mergedHeaderModel != null + || modelCache[position]?.formattedDayModel != null) { modelCache[position] = buildItemModels(position, currentSnapshot) } } @@ -251,7 +257,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec val nextDate = nextEvent?.root?.localDateTime() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() - val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, callback).also { + val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, hideReadMarker, callback).also { it.id(event.localId) it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt index dd50bbf190..a387f3f496 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt @@ -31,6 +31,7 @@ class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: Ava fun create(event: TimelineEvent, highlight: Boolean, + hideReadMarker: Boolean, callback: TimelineEventController.Callback?, exception: Exception? = null): DefaultItem? { val text = if (exception == null) { @@ -39,7 +40,7 @@ class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: Ava "an exception occurred when rendering the event ${event.root.eventId}" } - val informationData = informationDataFactory.create(event, null) + val informationData = informationDataFactory.create(event, null, hideReadMarker) return DefaultItem_() .leftGuideline(avatarSizeProvider.leftGuideline) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt index 92f586ab7b..663762850a 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt @@ -41,6 +41,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat fun create(event: TimelineEvent, nextEvent: TimelineEvent?, highlight: Boolean, + hideReadMarker: Boolean, callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? { event.root.eventId ?: return null @@ -64,7 +65,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat // TODO This is not correct format for error, change it - val informationData = messageInformationDataFactory.create(event, nextEvent) + val informationData = messageInformationDataFactory.create(event, nextEvent, hideReadMarker) val attributes = attributesFactory.create(null, informationData, callback) return MessageTextItem_() .leftGuideline(avatarSizeProvider.leftGuideline) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index 42f0688e50..80b3aa261b 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -51,18 +51,22 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act return if (!event.canBeMerged() || (nextEvent?.root?.getClearType() == event.root.getClearType() && !addDaySeparator)) { null } else { - var highlighted = false - var showReadMarker = false val prevSameTypeEvents = items.prevSameTypeEvents(currentPosition, 2) if (prevSameTypeEvents.isEmpty()) { null } else { + var highlighted = false + var readMarkerId: String? = null + var showReadMarker = false val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed() val mergedData = ArrayList(mergedEvents.size) mergedEvents.forEach { mergedEvent -> if (!highlighted && mergedEvent.root.eventId == eventIdToHighlight) { highlighted = true } + if (readMarkerId == null && mergedEvent.hasReadMarker) { + readMarkerId = mergedEvent.root.eventId + } if (!showReadMarker && mergedEvent.displayReadMarker(sessionHolder.getActiveSession().myUserId)) { showReadMarker = true } @@ -81,7 +85,7 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act // => handle case where paginating from mergeable events and we get more val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull() val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey) - ?: true + ?: true val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState } if (isCollapsed) { collapsedEventIds.addAll(mergedEventIds) @@ -97,12 +101,14 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act mergeItemCollapseStates[event.localId] = it requestModelBuild() }, - showReadMarker = showReadMarker + readMarkerId = readMarkerId, + showReadMarker = isCollapsed && showReadMarker, + readReceiptsCallback = callback ) MergedHeaderItem_() .id(mergeId) .leftGuideline(avatarSizeProvider.leftGuideline) - .highlighted(highlighted) + .highlighted(isCollapsed && highlighted) .attributes(attributes) .also { it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents)) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 24d5abfd94..2cf5a60c44 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -76,11 +76,12 @@ class MessageItemFactory @Inject constructor( fun create(event: TimelineEvent, nextEvent: TimelineEvent?, highlight: Boolean, + hideReadMarker: Boolean, callback: TimelineEventController.Callback? ): VectorEpoxyModel<*>? { event.root.eventId ?: return null - val informationData = messageInformationDataFactory.create(event, nextEvent) + val informationData = messageInformationDataFactory.create(event, nextEvent, hideReadMarker) if (event.root.isRedacted()) { //message is redacted @@ -97,7 +98,7 @@ class MessageItemFactory @Inject constructor( || event.isEncrypted() && event.root.content.toModel()?.relatesTo?.type == RelationType.REPLACE ) { // This is an edit event, we should it when debugging as a notice event - return noticeItemFactory.create(event, highlight, callback) + return noticeItemFactory.create(event, highlight, hideReadMarker, callback) } val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt index bb301cdcbd..2f774cd9ec 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt @@ -36,9 +36,11 @@ class NoticeItemFactory @Inject constructor( fun create(event: TimelineEvent, highlight: Boolean, + hideReadMarker: Boolean, callback: TimelineEventController.Callback?): NoticeItem? { + val formattedText = eventFormatter.format(event) ?: return null - val informationData = informationDataFactory.create(event, null) + val informationData = informationDataFactory.create(event, null, hideReadMarker) val attributes = NoticeItem.Attributes( avatarRenderer = avatarRenderer, informationData = informationData, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index d75d43f840..18254120af 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -33,13 +33,14 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me fun create(event: TimelineEvent, nextEvent: TimelineEvent?, eventIdToHighlight: String?, + hideReadMarker: Boolean, callback: TimelineEventController.Callback?): VectorEpoxyModel<*> { val highlight = event.root.eventId == eventIdToHighlight val computedModel = try { when (event.root.getClearType()) { - EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, callback) + EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, hideReadMarker, callback) // State and call EventType.STATE_ROOM_TOMBSTONE, EventType.STATE_ROOM_NAME, @@ -51,22 +52,22 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.CALL_ANSWER, EventType.REACTION, EventType.REDACTION, - EventType.ENCRYPTION -> noticeItemFactory.create(event, highlight, callback) + EventType.ENCRYPTION -> noticeItemFactory.create(event, highlight, hideReadMarker, callback) // State room create EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback) // Crypto EventType.ENCRYPTED -> { if (event.root.isRedacted()) { // Redacted event, let the MessageItemFactory handle it - messageItemFactory.create(event, nextEvent, highlight, callback) + messageItemFactory.create(event, nextEvent, highlight, hideReadMarker, callback) } else { - encryptedItemFactory.create(event, nextEvent, highlight, callback) + encryptedItemFactory.create(event, nextEvent, highlight, hideReadMarker, callback) } } // Unhandled event types (yet) EventType.STATE_ROOM_THIRD_PARTY_INVITE, - EventType.STICKER -> defaultItemFactory.create(event, highlight, callback) + EventType.STICKER -> defaultItemFactory.create(event, highlight, hideReadMarker, callback) else -> { Timber.v("Type ${event.root.getClearType()} not handled") null @@ -74,7 +75,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me } } catch (e: Exception) { Timber.e(e, "failed to create message item") - defaultItemFactory.create(event, highlight, callback, e) + defaultItemFactory.create(event, highlight, hideReadMarker, callback, e) } return (computedModel ?: EmptyItem_()) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index b8a89a4669..453f7e4cd9 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -41,7 +41,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses private val dateFormatter: VectorDateFormatter, private val colorProvider: ColorProvider) { - fun create(event: TimelineEvent, nextEvent: TimelineEvent?): MessageInformationData { + fun create(event: TimelineEvent, nextEvent: TimelineEvent?, hideReadMarker: Boolean): MessageInformationData { // Non nullability has been tested before val eventId = event.root.eventId!! @@ -65,7 +65,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId ?: "")) } - val displayReadMarker = event.displayReadMarker(session.myUserId) + val displayReadMarker = !hideReadMarker && event.displayReadMarker(session.myUserId) return MessageInformationData( eventId = eventId, @@ -91,6 +91,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs) } .toList(), + hasReadMarker = event.hasReadMarker, displayReadMarker = displayReadMarker ) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt index 44a5e2bdfb..408a997efd 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -56,8 +56,9 @@ abstract class AbsMessageItem : BaseEventItem() { }) private val _readMarkerCallback = object : ReadMarkerView.Callback { - override fun onReadMarkerDisplayed() { - attributes.readReceiptsCallback?.onReadMarkerLongDisplayed(attributes.informationData) + + override fun onReadMarkerLongBound() { + attributes.readReceiptsCallback?.onReadMarkerLongDisplayed() } } @@ -106,7 +107,12 @@ abstract class AbsMessageItem : BaseEventItem() { holder.memberNameView.setOnLongClickListener(null) } holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) - holder.readMarkerView.bindView(attributes.informationData.displayReadMarker, _readMarkerCallback) + holder.readMarkerView.bindView( + attributes.informationData.eventId, + attributes.informationData.hasReadMarker, + attributes.informationData.displayReadMarker, + _readMarkerCallback + ) if (!shouldShowReactionAtBottom() || attributes.informationData.orderedReactionList.isNullOrEmpty()) { holder.reactionWrapper?.isVisible = false diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt index f07575e1a5..de105b2261 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt @@ -25,7 +25,9 @@ import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.riotx.R +import im.vector.riotx.core.ui.views.ReadMarkerView import im.vector.riotx.features.home.AvatarRenderer +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController @EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo) abstract class MergedHeaderItem : BaseEventItem() { @@ -37,6 +39,13 @@ abstract class MergedHeaderItem : BaseEventItem() { attributes.mergeData.distinctBy { it.userId } } + private val _readMarkerCallback = object : ReadMarkerView.Callback { + + override fun onReadMarkerLongBound() { + attributes.readReceiptsCallback?.onReadMarkerLongDisplayed() + } + } + override fun getViewType() = STUB_ID override fun bind(holder: Holder) { @@ -68,6 +77,16 @@ abstract class MergedHeaderItem : BaseEventItem() { } // No read receipt for this item holder.readReceiptsView.isVisible = false + holder.readMarkerView.bindView( + attributes.readMarkerId, + !attributes.readMarkerId.isNullOrEmpty(), + attributes.showReadMarker, + _readMarkerCallback) + } + + override fun unbind(holder: Holder) { + holder.readMarkerView.unbind() + super.unbind(holder) } data class Data( @@ -78,10 +97,12 @@ abstract class MergedHeaderItem : BaseEventItem() { ) data class Attributes( + val readMarkerId: String?, val isCollapsed: Boolean, val showReadMarker: Boolean, val mergeData: List, val avatarRenderer: AvatarRenderer, + val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, val onCollapsedStateChanged: (Boolean) -> Unit ) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt index 041b6dbddd..09d51cacfd 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt @@ -34,6 +34,7 @@ data class MessageInformationData( val hasBeenEdited: Boolean = false, val hasPendingEdits: Boolean = false, val readReceipts: List = emptyList(), + val hasReadMarker: Boolean = false, val displayReadMarker: Boolean = false ) : Parcelable diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt index 8e61a3be1f..89270ce026 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt @@ -38,8 +38,8 @@ abstract class NoticeItem : BaseEventItem() { }) private val _readMarkerCallback = object : ReadMarkerView.Callback { - override fun onReadMarkerDisplayed() { - attributes.readReceiptsCallback?.onReadMarkerLongDisplayed(attributes.informationData) + override fun onReadMarkerLongBound() { + attributes.readReceiptsCallback?.onReadMarkerLongDisplayed() } } @@ -50,12 +50,17 @@ abstract class NoticeItem : BaseEventItem() { attributes.informationData.avatarUrl, attributes.informationData.senderId, attributes.informationData.memberName?.toString() - ?: attributes.informationData.senderId, + ?: attributes.informationData.senderId, holder.avatarImageView ) holder.view.setOnLongClickListener(attributes.itemLongClickListener) holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) - holder.readMarkerView.bindView(attributes.informationData.displayReadMarker, _readMarkerCallback) + holder.readMarkerView.bindView( + attributes.informationData.eventId, + attributes.informationData.hasReadMarker, + attributes.informationData.displayReadMarker, + _readMarkerCallback + ) } override fun unbind(holder: Holder) { From 9668487b6b7dc80ed384e3a6da0dcb065d4baaa1 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 19 Sep 2019 16:17:58 +0200 Subject: [PATCH 09/22] Timeline/Read: update read receipt locally to --- .../session/room/read/DefaultReadService.kt | 1 - .../session/room/read/SetReadMarkersTask.kt | 37 ++++++------------- .../session/sync/ReadReceiptHandler.kt | 16 ++++++++ .../riotx/core/ui/views/ReadMarkerView.kt | 2 + .../home/room/detail/RoomDetailFragment.kt | 2 +- .../home/room/detail/RoomDetailViewModel.kt | 16 ++++---- .../timeline/TimelineEventController.kt | 29 +++++++-------- 7 files changed, 50 insertions(+), 53 deletions(-) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt index 1f0bae6fea..24263c1047 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt @@ -32,7 +32,6 @@ import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntity import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity -import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.query.find import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.where diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt index beaf4eb0af..16be19a867 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt @@ -30,6 +30,7 @@ import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.network.executeRequest import im.vector.matrix.android.internal.session.room.RoomAPI import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory +import im.vector.matrix.android.internal.session.sync.ReadReceiptHandler import im.vector.matrix.android.internal.session.sync.RoomFullyReadHandler import im.vector.matrix.android.internal.task.Task import im.vector.matrix.android.internal.util.awaitTransaction @@ -53,7 +54,8 @@ private const val READ_RECEIPT = "m.read" internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI: RoomAPI, private val credentials: Credentials, private val monarchy: Monarchy, - private val roomFullyReadHandler: RoomFullyReadHandler + private val roomFullyReadHandler: RoomFullyReadHandler, + private val readReceiptHandler: ReadReceiptHandler ) : SetReadMarkersTask { override suspend fun execute(params: SetReadMarkersTask.Params) { @@ -82,7 +84,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI } if (readReceiptEventId != null - && !isEventRead(params.roomId, readReceiptEventId)) { + && !isEventRead(params.roomId, readReceiptEventId)) { if (LocalEchoEventFactory.isLocalEchoId(readReceiptEventId)) { Timber.w("Can't set read receipt for local event $readReceiptEventId") } else { @@ -102,15 +104,16 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI monarchy.awaitTransaction { realm -> val readMarkerId = markers[READ_MARKER] val readReceiptId = markers[READ_RECEIPT] - if (readMarkerId != null) { roomFullyReadHandler.handle(realm, roomId, FullyReadContent(readMarkerId)) } if (readReceiptId != null) { + val readReceiptContent = ReadReceiptHandler.createContent(credentials.userId, readReceiptId) + readReceiptHandler.handle(realm, roomId, readReceiptContent, false) val isLatestReceived = TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId == readReceiptId if (isLatestReceived) { val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst() - ?: return@awaitTransaction + ?: return@awaitTransaction roomSummary.notificationCount = 0 roomSummary.highlightCount = 0 } @@ -118,7 +121,6 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI } } - private fun isReadMarkerMoreRecent(roomId: String, fullyReadEventId: String): Boolean { return Realm.getInstance(monarchy.realmConfiguration).use { realm -> val readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId).findFirst() @@ -130,36 +132,19 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI } } - private fun isEventRead(roomId: String, eventId: String): Boolean { return Realm.getInstance(monarchy.realmConfiguration).use { realm -> val readReceipt = ReadReceiptEntity.where(realm, roomId, credentials.userId).findFirst() - ?: return false + ?: return false val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) - ?: return false + ?: return false val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex - ?: Int.MIN_VALUE + ?: Int.MIN_VALUE val eventToCheckIndex = liveChunk.timelineEvents.find(eventId)?.root?.displayIndex - ?: Int.MAX_VALUE + ?: Int.MAX_VALUE eventToCheckIndex <= readReceiptIndex } } - private fun SetReadMarkersTask.Params.fullyReadEventId(): String? { - if (fullyReadEventId != null) { - return this.fullyReadEventId - } else { - Realm.getInstance(monarchy.realmConfiguration).use { realm -> - val readReceipt = ReadReceiptEntity.where(realm, roomId, credentials.userId).findFirst() - val readMarker = ReadMarkerEntity.where(realm, roomId).findFirst() - return if (readMarker?.eventId == readReceipt?.eventId) { - readReceiptEventId - } else { - null - } - } - } - } - } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/ReadReceiptHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/ReadReceiptHandler.kt index e61e81dd16..192a11fa68 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/ReadReceiptHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/ReadReceiptHandler.kt @@ -38,6 +38,22 @@ private const val TIMESTAMP_KEY = "ts" internal class ReadReceiptHandler @Inject constructor() { + companion object { + + fun createContent(userId: String, eventId: String): ReadReceiptContent { + return mapOf( + eventId to mapOf( + READ_KEY to mapOf( + userId to mapOf( + TIMESTAMP_KEY to System.currentTimeMillis().toDouble() + ) + ) + ) + ) + } + + } + fun handle(realm: Realm, roomId: String, content: ReadReceiptContent?, isInitialSync: Boolean) { if (content == null) { return diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt index 986acef616..55665ca27f 100644 --- a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt @@ -26,6 +26,7 @@ import android.view.animation.AnimationUtils import androidx.core.view.isInvisible import im.vector.riotx.R import kotlinx.coroutines.* +import timber.log.Timber private const val DELAY_IN_MS = 1_500L @@ -44,6 +45,7 @@ class ReadMarkerView @JvmOverloads constructor( private var callbackDispatcherJob: Job? = null fun bindView(eventId: String?, hasReadMarker: Boolean, displayReadMarker: Boolean, readMarkerCallback: Callback) { + Timber.v("Bind event $eventId - hasReadMarker: $hasReadMarker - displayReadMarker: $displayReadMarker") this.eventId = eventId this.callback = readMarkerCallback if (displayReadMarker) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 0bb45ccfb8..d689f019e7 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -687,7 +687,7 @@ class RoomDetailFragment : val summary = state.asyncRoomSummary() val inviter = state.asyncInviter() if (summary?.membership == Membership.JOIN) { - timelineEventController.update(state.timeline, state.highlightedEventId, state.hideReadMarker) + timelineEventController.update(state) inviteView.visibility = View.GONE val uid = session.myUserId val meMember = session.getRoom(state.roomId)?.getRoomMember(uid) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index afe4ea6681..978e63f7f1 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -158,7 +158,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) { val tombstoneContent = action.event.getClearContent().toModel() - ?: return + ?: return val roomId = tombstoneContent.replacementRoom ?: "" val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN @@ -305,7 +305,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro //is original event a reply? val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel()?.relatesTo?.inReplyTo?.eventId - ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId + ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId if (inReplyTo != null) { //TODO check if same content? room.getTimeLineEvent(inReplyTo)?.let { @@ -314,12 +314,12 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } else { val messageContent: MessageContent? = state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() - ?: state.sendMode.timelineEvent.root.getClearContent().toModel() + ?: state.sendMode.timelineEvent.root.getClearContent().toModel() val existingBody = messageContent?.body ?: "" if (existingBody != action.text) { room.editTextMessage(state.sendMode.timelineEvent.root.eventId - ?: "", messageContent?.type - ?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown) + ?: "", messageContent?.type + ?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown) } else { Timber.w("Same message content, do not send edition") } @@ -334,7 +334,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro is SendMode.QUOTE -> { val messageContent: MessageContent? = state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() - ?: state.sendMode.timelineEvent.root.getClearContent().toModel() + ?: state.sendMode.timelineEvent.root.getClearContent().toModel() val textMsg = messageContent?.body val finalText = legacyRiotQuoteText(textMsg, action.text) @@ -645,7 +645,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro Option.empty() } else { val readMarkerIndex = room.getTimeLineEvent(readMarkerId)?.displayIndex - ?: Int.MIN_VALUE + ?: Int.MIN_VALUE Option.just(readMarkerIndex) } } @@ -694,8 +694,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro readMarker.getOrNull() == readReceipt.getOrNull() } ) - .throttleLast(250, TimeUnit.MILLISECONDS) - .distinctUntilChanged() .startWith(false) .subscribe { setState { copy(hideReadMarker = it) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index b5a5fe8ca8..3aeac06f12 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -31,6 +31,7 @@ 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.features.home.room.detail.RoomDetailViewState import im.vector.riotx.features.home.room.detail.timeline.factory.MergedHeaderItemFactory import im.vector.riotx.features.home.room.detail.timeline.factory.TimelineItemFactory import im.vector.riotx.features.home.room.detail.timeline.helper.* @@ -140,11 +141,10 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec requestModelBuild() } - fun update(timeline: Timeline?, eventIdToHighlight: String?, hideReadMarker: Boolean) { - if (this.timeline != timeline) { - this.timeline = timeline - this.timeline?.listener = this - + fun update(viewState: RoomDetailViewState) { + if (timeline != viewState.timeline) { + timeline = viewState.timeline + timeline?.listener = this // Clear cache synchronized(modelCache) { for (i in 0 until modelCache.size) { @@ -152,23 +152,22 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } } - var requestModelBuild = false - if (this.eventIdToHighlight != eventIdToHighlight) { + if (eventIdToHighlight != viewState.highlightedEventId) { // 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) { + if (modelCache[i]?.eventId == viewState.highlightedEventId + || modelCache[i]?.eventId == eventIdToHighlight) { modelCache[i] = null } } } - this.eventIdToHighlight = eventIdToHighlight + eventIdToHighlight = viewState.highlightedEventId requestModelBuild = true } - if (this.hideReadMarker != hideReadMarker) { - this.hideReadMarker = hideReadMarker + if (hideReadMarker != viewState.hideReadMarker) { + hideReadMarker = viewState.hideReadMarker requestModelBuild = true } if (requestModelBuild) { @@ -230,8 +229,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec // 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]?.mergedHeaderModel != null + || modelCache[position]?.formattedDayModel != null) { modelCache[position] = buildItemModels(position, currentSnapshot) } } @@ -256,7 +255,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec val date = event.root.localDateTime() val nextDate = nextEvent?.root?.localDateTime() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() - val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, hideReadMarker, callback).also { it.id(event.localId) it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) @@ -298,7 +296,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } - fun searchPositionOfEvent(eventId: String): Int? = synchronized(modelCache) { // Search in the cache var realPosition = 0 From d1ff3314a74a28618b1e4b46cc6d96312f933c0f Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 19 Sep 2019 19:12:45 +0200 Subject: [PATCH 10/22] Timeline : add badge on jump to bottom view --- .../platform/BadgeFloatingActionButton.kt | 186 ++++++++++++++++++ .../home/room/detail/RoomDetailFragment.kt | 2 + .../main/res/layout/fragment_room_detail.xml | 6 +- .../src/main/res/values/attrs_badge_fab.xml | 12 ++ 4 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 vector/src/main/java/im/vector/riotx/core/platform/BadgeFloatingActionButton.kt create mode 100644 vector/src/main/res/values/attrs_badge_fab.xml diff --git a/vector/src/main/java/im/vector/riotx/core/platform/BadgeFloatingActionButton.kt b/vector/src/main/java/im/vector/riotx/core/platform/BadgeFloatingActionButton.kt new file mode 100644 index 0000000000..545fd9409f --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/platform/BadgeFloatingActionButton.kt @@ -0,0 +1,186 @@ +/* + * 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.core.platform + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Paint.ANTI_ALIAS_FLAG +import android.graphics.PointF +import android.graphics.Rect +import android.graphics.RectF +import android.text.TextPaint +import android.util.AttributeSet +import androidx.core.content.res.use +import com.google.android.material.floatingactionbutton.FloatingActionButton +import im.vector.riotx.R +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sin + +class BadgeFloatingActionButton @JvmOverloads constructor( + context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 +) : FloatingActionButton(context, attrs, defStyleAttr) { + + private val textPaint = TextPaint(ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.LEFT + } + private val tintPaint = Paint(ANTI_ALIAS_FLAG) + + private var countStr: String + private var countMaxStr: String + private var counterBounds: RectF = RectF() + private var counterTextBounds: Rect = Rect() + private var counterMaxTextBounds: Rect = Rect() + private var counterPossibleCenter: PointF = PointF() + + private var fabBounds: Rect = Rect() + + var counterTextColor: Int + get() = textPaint.color + set(value) { + val was = textPaint.color + if (was != value) { + textPaint.color = value + invalidate() + } + } + + var counterBackgroundColor: Int + get() = tintPaint.color + set(value) { + val was = tintPaint.color + if (was != value) { + tintPaint.color = value + invalidate() + } + } + + var counterTextSize: Float + get() = textPaint.textSize + set(value) { + val was = textPaint.textSize + if (was != value) { + textPaint.textSize = value + invalidate() + requestLayout() + } + } + + var counterTextPadding: Float = 0f + set(value) { + if (field != value) { + field = value + invalidate() + requestLayout() + } + } + + + var maxCount: Int = 99 + set(value) { + if (field != value) { + field = value + countMaxStr = "$value+" + + requestLayout() + } + } + + var count: Int = 0 + set(value) { + if (field != value) { + field = value + countStr = countStr(value) + textPaint.getTextBounds(countStr, 0, countStr.length, counterTextBounds) + invalidate() + } + } + + init { + countStr = countStr(count) + textPaint.getTextBounds(countStr, 0, countStr.length, counterTextBounds) + countMaxStr = "$maxCount+" + + attrs?.let { initAttrs(attrs) } + } + + @SuppressWarnings("ResourceType", "Recycle") + private fun initAttrs(attrs: AttributeSet) { + context.obtainStyledAttributes(attrs, R.styleable.BadgeFloatingActionButton).use { + counterBackgroundColor = it.getColor(R.styleable.BadgeFloatingActionButton_badgeBackgroundColor, 0) + counterTextPadding = it.getDimension(R.styleable.BadgeFloatingActionButton_badgeTextPadding, 0f) + counterTextSize = it.getDimension(R.styleable.BadgeFloatingActionButton_badgeTextSize, 14f) + counterTextColor = it.getColor(R.styleable.BadgeFloatingActionButton_badgeTextColor, Color.WHITE) + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + calculateCounterBounds(counterBounds) + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + + if (count > 0) { + canvas.drawCircle(counterBounds.centerX(), counterBounds.centerY(), counterBounds.width() / 2f, tintPaint) + + val textX = counterBounds.centerX() - counterTextBounds.width() / 2f - counterTextBounds.left + val textY = counterBounds.centerY() + counterTextBounds.height() / 2f - counterTextBounds.bottom + canvas.drawText(countStr, textX, textY, textPaint) + } + } + + private fun calculateCounterBounds(outRect: RectF) { + getMeasuredContentRect(fabBounds) + calculateCounterCenter(fabBounds, counterPossibleCenter) + + textPaint.getTextBounds(countMaxStr, 0, countMaxStr.length, counterMaxTextBounds) + val counterDiameter = max(counterMaxTextBounds.width(), counterMaxTextBounds.height()) + 2 * counterTextPadding + + val counterRight = min(counterPossibleCenter.x + counterDiameter / 2, fabBounds.right.toFloat()) + val counterTop = max(counterPossibleCenter.y - counterDiameter / 2, fabBounds.top.toFloat()) + + outRect.set(counterRight - counterDiameter, counterTop, counterRight, counterTop + counterDiameter) + } + + private fun calculateCounterCenter(inBounds: Rect, outPoint: PointF) { + val radius = min(inBounds.width(), inBounds.height()) / 2f + calculateCounterCenter(radius, outPoint) + outPoint.x = inBounds.centerX() + outPoint.x + outPoint.y = inBounds.centerY() - outPoint.y + } + + private fun calculateCounterCenter(radius: Float, outPoint: PointF) = + calculateCounterCenter(radius, (PI / 4).toFloat(), outPoint) + + private fun calculateCounterCenter(radius: Float, angle: Float, outPoint: PointF) { + outPoint.x = radius * cos(angle) + outPoint.y = radius * sin(angle) + } + + private fun countStr(count: Int) = if (count > maxCount) "$maxCount+" else count.toString() + + companion object { + val TEXT_APPEARANCE_SUPPORTED_ATTRS = intArrayOf(android.R.attr.textSize, android.R.attr.textColor) + } + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 8051d42611..58ab211eb4 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -739,6 +739,8 @@ class RoomDetailFragment : avatarRenderer.render(it, roomToolbarAvatarImageView) roomToolbarSubtitleView.setTextOrHide(it.topic) } + + jumpToBottomView.count = it.notificationCount } } diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml index 3a68814eb1..f95afbd647 100644 --- a/vector/src/main/res/layout/fragment_room_detail.xml +++ b/vector/src/main/res/layout/fragment_room_detail.xml @@ -161,13 +161,17 @@ app:layout_constraintTop_toBottomOf="@+id/roomToolbar" tools:visibility="visible" /> - + + + + + + + + + + + \ No newline at end of file From 90eeb68d362cd1be0ef6592f7dd904c3cb67cf7a Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 20 Sep 2019 17:22:04 +0200 Subject: [PATCH 11/22] Timeline: fix permalink towards an hidden event --- .../api/session/room/timeline/Timeline.kt | 4 +- .../session/room/timeline/DefaultTimeline.kt | 79 +++++++++++++------ .../room/timeline/TokenChunkEventPersistor.kt | 10 +-- .../home/room/detail/RoomDetailFragment.kt | 1 + .../home/room/detail/RoomDetailViewModel.kt | 23 ++---- .../ScrollOnHighlightedEventCallback.kt | 7 +- .../timeline/TimelineEventController.kt | 5 +- 7 files changed, 81 insertions(+), 48 deletions(-) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt index d0f4bff74b..13eca813c7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt @@ -44,7 +44,6 @@ interface Timeline { */ fun dispose() - fun restartWithEventId(eventId: String?) @@ -73,8 +72,9 @@ interface Timeline { fun getTimelineEventWithId(eventId: String?): TimelineEvent? + fun getFirstDisplayableEventId(eventId: String): String? - interface Listener { + interface Listener { /** * Call when the timeline has been updated through pagination or sync. * @param snapshot the most uptodate snapshot diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index 88c13cc056..b3d83a2286 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -99,7 +99,8 @@ internal class DefaultTimeline( private val cancelableBag = CancelableBag() private val debouncer = Debouncer(mainHandler) - private lateinit var liveEvents: RealmResults + private lateinit var nonFilteredEvents: RealmResults + private lateinit var filteredEvents: RealmResults private lateinit var eventRelations: RealmResults private var roomEntity: RoomEntity? = null @@ -128,9 +129,9 @@ internal class DefaultTimeline( } changeSet.insertionRanges.forEach { range -> val (startDisplayIndex, direction) = if (range.startIndex == 0) { - Pair(liveEvents[range.length - 1]!!.root!!.displayIndex, Timeline.Direction.FORWARDS) + Pair(filteredEvents[range.length - 1]!!.root!!.displayIndex, Timeline.Direction.FORWARDS) } else { - Pair(liveEvents[range.startIndex]!!.root!!.displayIndex, Timeline.Direction.BACKWARDS) + Pair(filteredEvents[range.startIndex]!!.root!!.displayIndex, Timeline.Direction.BACKWARDS) } val state = getPaginationState(direction) if (state.isPaginating) { @@ -218,9 +219,9 @@ internal class DefaultTimeline( } } - liveEvents = buildEventQuery(realm) + nonFilteredEvents = buildEventQuery(realm).sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.DESCENDING).findAll() + filteredEvents = nonFilteredEvents.where() .filterEventsWithSettings() - .sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.DESCENDING) .findAllAsync() .also { it.addChangeListener(eventsChangeListener) } @@ -229,9 +230,9 @@ internal class DefaultTimeline( .also { it.addChangeListener(relationsListener) } if (settings.buildReadReceipts) { - hiddenReadReceipts.start(realm, liveEvents, this) + hiddenReadReceipts.start(realm, filteredEvents, this) } - hiddenReadMarker.start(realm, liveEvents, this) + hiddenReadMarker.start(realm, filteredEvents, this) isReady.set(true) } } @@ -246,7 +247,7 @@ internal class DefaultTimeline( BACKGROUND_HANDLER.post { roomEntity?.sendingTimelineEvents?.removeAllChangeListeners() eventRelations.removeAllChangeListeners() - liveEvents.removeAllChangeListeners() + filteredEvents.removeAllChangeListeners() hiddenReadMarker.dispose() if (settings.buildReadReceipts) { hiddenReadReceipts.dispose() @@ -267,25 +268,56 @@ internal class DefaultTimeline( postSnapshot() } - override fun getIndexOfEvent(eventId: String?): Int? { - return builtEventsIdMap[eventId] - } - override fun getTimelineEventAtIndex(index: Int): TimelineEvent? { return builtEvents.getOrNull(index) } + override fun getIndexOfEvent(eventId: String?): Int? { + return builtEventsIdMap[eventId] + } + override fun getTimelineEventWithId(eventId: String?): TimelineEvent? { return builtEventsIdMap[eventId]?.let { getTimelineEventAtIndex(it) } } + override fun getFirstDisplayableEventId(eventId: String): String? { + // If the item is built, the id is obviously displayable + val builtIndex = builtEventsIdMap[eventId] + if (builtIndex != null) { + return eventId + } + // Otherwise, we should check if the event is in the db, but is hidden because of filters + return Realm.getInstance(realmConfiguration).use { localRealm -> + val nonFilteredEvents = buildEventQuery(localRealm).sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.DESCENDING).findAll() + val nonFilteredEvent = nonFilteredEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, eventId).findFirst() + val filteredEvents = nonFilteredEvents.where().filterEventsWithSettings().findAll() + val isEventInDb = nonFilteredEvent != null + + val isHidden = isEventInDb && filteredEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, eventId).findFirst() == null + if (isHidden) { + val displayIndex = nonFilteredEvent?.root?.displayIndex + if (displayIndex != null) { + // Then we are looking for the first displayable event after the hidden one + val firstDisplayedEvent = filteredEvents.where() + .lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex) + .findFirst() + firstDisplayedEvent?.eventId + } else { + null + } + } else { + null + } + } + } + override fun hasMoreToLoad(direction: Timeline.Direction): Boolean { return hasMoreInCache(direction) || !hasReachedEnd(direction) } - // TimelineHiddenReadReceipts.Delegate +// TimelineHiddenReadReceipts.Delegate override fun rebuildEvent(eventId: String, readReceipts: List): Boolean { return rebuildEvent(eventId) { te -> @@ -297,7 +329,7 @@ internal class DefaultTimeline( postSnapshot() } - // TimelineHiddenReadMarker.Delegate +// TimelineHiddenReadMarker.Delegate override fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean { return rebuildEvent(eventId) { te -> @@ -309,7 +341,7 @@ internal class DefaultTimeline( postSnapshot() } - // Private methods ***************************************************************************** +// Private methods ***************************************************************************** private fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent): Boolean { return builtEventsIdMap[eventId]?.let { builtIndex -> @@ -415,22 +447,23 @@ internal class DefaultTimeline( */ private fun handleInitialLoad() { var shouldFetchInitialEvent = false - val initialDisplayIndex = if (initialEventId == null) { - liveEvents.firstOrNull()?.root?.displayIndex + val currentInitialEventId = initialEventId + val initialDisplayIndex = if (currentInitialEventId == null) { + filteredEvents.firstOrNull()?.root?.displayIndex } else { - val initialEvent = liveEvents.where() + val initialEvent = nonFilteredEvents.where() .equalTo(TimelineEventEntityFields.EVENT_ID, initialEventId) .findFirst() + shouldFetchInitialEvent = initialEvent == null initialEvent?.root?.displayIndex } prevDisplayIndex = initialDisplayIndex nextDisplayIndex = initialDisplayIndex - val currentInitialEventId = initialEventId if (currentInitialEventId != null && shouldFetchInitialEvent) { fetchEvent(currentInitialEventId) } else { - val count = min(settings.initialSize, liveEvents.size) + val count = min(settings.initialSize, filteredEvents.size) if (initialEventId == null) { paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count, strict = false) } else { @@ -494,7 +527,7 @@ internal class DefaultTimeline( * This has to be called on TimelineThread as it access realm live results */ private fun getLiveChunk(): ChunkEntity? { - return liveEvents.firstOrNull()?.chunk?.firstOrNull() + return filteredEvents.firstOrNull()?.chunk?.firstOrNull() } /** @@ -552,7 +585,7 @@ internal class DefaultTimeline( direction: Timeline.Direction, count: Long, strict: Boolean): RealmResults { - val offsetQuery = liveEvents.where() + val offsetQuery = filteredEvents.where() if (direction == Timeline.Direction.BACKWARDS) { offsetQuery.sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.DESCENDING) if (strict) { @@ -631,7 +664,7 @@ internal class DefaultTimeline( } -// Extension methods *************************************************************************** + // Extension methods *************************************************************************** private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt index af845040ae..0305002959 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -112,7 +112,7 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy Timber.v("Start persisting ${receivedChunk.events.size} events in $roomId towards $direction") val roomEntity = RoomEntity.where(realm, roomId).findFirst() - ?: realm.createObject(roomId) + ?: realm.createObject(roomId) val nextToken: String? val prevToken: String? @@ -141,7 +141,7 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy } else { nextChunk?.apply { this.prevToken = prevToken } } - ?: ChunkEntity.create(realm, prevToken, nextToken) + ?: ChunkEntity.create(realm, prevToken, nextToken) if (receivedChunk.events.isEmpty() && receivedChunk.end == receivedChunk.start) { Timber.v("Reach end of $roomId") @@ -163,8 +163,8 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy currentChunk = handleMerge(roomEntity, direction, currentChunk, nextChunk) } else { val newEventIds = receivedChunk.events.mapNotNull { it.eventId } - ChunkEntity - .findAllIncludingEvents(realm, newEventIds) + val overlappedChunks = ChunkEntity.findAllIncludingEvents(realm, newEventIds) + overlappedChunks .filter { it != currentChunk } .forEach { overlapped -> currentChunk = handleMerge(roomEntity, direction, currentChunk, overlapped) @@ -194,7 +194,7 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy // We always merge the bottom chunk into top chunk, so we are always merging backwards Timber.v("Merge ${currentChunk.prevToken} | ${currentChunk.nextToken} with ${otherChunk.prevToken} | ${otherChunk.nextToken}") - return if (direction == PaginationDirection.BACKWARDS) { + return if (direction == PaginationDirection.BACKWARDS && !otherChunk.isLastForward) { currentChunk.merge(roomEntity.roomId, otherChunk, PaginationDirection.BACKWARDS) roomEntity.deleteOnCascade(otherChunk) currentChunk diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 58ab211eb4..f6d50046a9 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -702,6 +702,7 @@ class RoomDetailFragment : val summary = state.asyncRoomSummary() val inviter = state.asyncInviter() if (summary?.membership == Membership.JOIN) { + scrollOnHighlightedEventCallback.timeline = state.timeline timelineEventController.update(state) inviteView.visibility = View.GONE val uid = session.myUserId diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index 401411dfde..72a09fbad6 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -43,6 +43,7 @@ import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.getFileUrl +import im.vector.matrix.android.api.session.room.model.relation.ReactionContent import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent import im.vector.matrix.android.api.session.room.send.UserDraft import im.vector.matrix.android.api.session.room.timeline.TimelineEvent @@ -613,18 +614,18 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } - private fun handleNavigateToEvent(action: RoomDetailActions.NavigateToEvent) { - val targetEventId = action.eventId - val indexOfEvent = timeline.getIndexOfEvent(targetEventId) + val targetEventId: String = action.eventId + val correctedEventId = timeline.getFirstDisplayableEventId(targetEventId) ?: targetEventId + val indexOfEvent = timeline.getIndexOfEvent(correctedEventId) if (indexOfEvent == null) { // Event is not already in RAM timeline.restartWithEventId(targetEventId) } if (action.highlight) { - setState { copy(highlightedEventId = targetEventId) } + setState { copy(highlightedEventId = correctedEventId) } } - _navigateToEvent.postLiveEvent(targetEventId) + _navigateToEvent.postLiveEvent(correctedEventId) } private fun handleResendEvent(action: RoomDetailActions.ResendMessage) { @@ -683,17 +684,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } private fun handleSetReadMarkerAction(action: RoomDetailActions.SetReadMarkerAction) = withState { state -> - var readMarkerId = action.eventId - if (readMarkerId == state.asyncRoomSummary()?.readMarkerId) { - val indexOfEvent = timeline.getIndexOfEvent(action.eventId) - // force to set the read marker on the next event - if (indexOfEvent != null) { - timeline.getTimelineEventAtIndex(indexOfEvent - 1)?.root?.eventId?.also { eventIdOfNext -> - readMarkerId = eventIdOfNext - } - } - } - room.setReadMarker(readMarkerId, callback = object : MatrixCallback {}) + room.setReadMarker(action.eventId, callback = object : MatrixCallback {}) } private fun handleMarkAllAsRead() { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt index c272e611a0..62d80408d2 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt @@ -17,6 +17,7 @@ package im.vector.riotx.features.home.room.detail import androidx.recyclerview.widget.LinearLayoutManager +import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.riotx.core.platform.DefaultListUpdateCallback import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import timber.log.Timber @@ -27,9 +28,13 @@ class ScrollOnHighlightedEventCallback(private val layoutManager: LinearLayoutMa private val scheduledEventId = AtomicReference() + var timeline: Timeline? = null + override fun onChanged(position: Int, count: Int, tag: Any?) { val eventId = scheduledEventId.get() ?: return - val positionToScroll = timelineEventController.searchPositionOfEvent(eventId) + val nonNullTimeline = timeline ?: return + val correctedEventId = nonNullTimeline.getFirstDisplayableEventId(eventId) + val positionToScroll = timelineEventController.searchPositionOfEvent(correctedEventId) if (positionToScroll != null) { val firstVisibleItem = layoutManager.findFirstCompletelyVisibleItemPosition() val lastVisibleItem = layoutManager.findLastCompletelyVisibleItemPosition() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index 3aeac06f12..701e412b1d 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -296,8 +296,11 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } - fun searchPositionOfEvent(eventId: String): Int? = synchronized(modelCache) { + fun searchPositionOfEvent(eventId: String?): Int? = synchronized(modelCache) { // Search in the cache + if (eventId == null) { + return null + } var realPosition = 0 if (showingForwardLoader) { realPosition++ From 7e29665fd0d5d8c3ebaf3745d4e77428390b53b1 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 20 Sep 2019 18:33:57 +0200 Subject: [PATCH 12/22] Timeline: add some comments and checks --- .../api/session/room/timeline/Timeline.kt | 26 +- .../session/room/timeline/DefaultTimeline.kt | 3 + .../room/timeline/TimelineHiddenReadMarker.kt | 5 +- .../timeline/TimelineHiddenReadReceipts.kt | 3 + .../home/room/detail/RoomDetailFragment.kt | 1140 ++++++++--------- .../ScrollOnHighlightedEventCallback.kt | 14 + 6 files changed, 619 insertions(+), 572 deletions(-) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt index 13eca813c7..9873b75e70 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt @@ -44,6 +44,10 @@ interface Timeline { */ fun dispose() + /** + * This method restarts the timeline, erases all built events and pagination states. + * It then loads events around the eventId. If eventId is null, it does restart the live timeline. + */ fun restartWithEventId(eventId: String?) @@ -62,19 +66,39 @@ interface Timeline { */ fun paginate(direction: Direction, count: Int) + /** + * Returns the number of sending events + */ fun pendingEventCount(): Int + /** + * Returns the number of failed sending events. + */ fun failedToDeliverEventCount(): Int + /** + * Returns the index of a built event or null. + */ fun getIndexOfEvent(eventId: String?): Int? + /** + * Returns the built [TimelineEvent] at index or null + */ fun getTimelineEventAtIndex(index: Int): TimelineEvent? + /** + * Returns the built [TimelineEvent] with eventId or null + */ fun getTimelineEventWithId(eventId: String?): TimelineEvent? + /** + * Returns the first displayable events starting from eventId. + * It does depend on the provided [TimelineSettings]. + */ fun getFirstDisplayableEventId(eventId: String): String? - interface Listener { + + interface Listener { /** * Call when the timeline has been updated through pagination or sync. * @param snapshot the most uptodate snapshot diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index b3d83a2286..f22be74f70 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -120,6 +120,9 @@ internal class DefaultTimeline( private val eventDecryptor = TimelineEventDecryptor(realmConfiguration, timelineID, cryptoService) private val eventsChangeListener = OrderedRealmCollectionChangeListener> { results, changeSet -> + if (!results.isLoaded || !results.isValid) { + return@OrderedRealmCollectionChangeListener + } if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL) { handleInitialLoad() } else { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt index 532a66140e..7ae6cbcfe1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt @@ -45,6 +45,9 @@ internal class TimelineHiddenReadMarker constructor(private val roomId: String) private lateinit var delegate: Delegate private val readMarkerListener = RealmObjectChangeListener { readMarker, _ -> + if (!readMarker.isLoaded || !readMarker.isValid) { + return@RealmObjectChangeListener + } var hasChange = false previousDisplayedEventId?.also { hasChange = delegate.rebuildEvent(it, false) @@ -53,7 +56,7 @@ internal class TimelineHiddenReadMarker constructor(private val roomId: String) val isEventHidden = liveEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, readMarker.eventId).findFirst() == null if (isEventHidden) { val hiddenEvent = readMarker.timelineEvent?.firstOrNull() - ?: return@RealmObjectChangeListener + ?: return@RealmObjectChangeListener val displayIndex = hiddenEvent.root?.displayIndex if (displayIndex != null) { // Then we are looking for the first displayable event after the hidden one diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt index 5408668576..f932e6f3c0 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt @@ -53,6 +53,9 @@ internal class TimelineHiddenReadReceipts constructor(private val readReceiptsSu private lateinit var delegate: Delegate private val hiddenReadReceiptsListener = OrderedRealmCollectionChangeListener> { collection, changeSet -> + if (!collection.isLoaded || !collection.isValid) { + return@OrderedRealmCollectionChangeListener + } var hasChange = false // Deletion here means we don't have any readReceipts for the given hidden events changeSet.deletions.forEach { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index f6d50046a9..72f0005d4c 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -507,614 +507,614 @@ class RoomDetailFragment : R.drawable.ic_reply, object : RoomMessageTouchHelperCallback.QuickReplayHandler { override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { - (model as? AbsMessageItem)?.attributes?.informationData?.let { - val eventId = it.eventId - roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString())) - } + (model as? AbsMessageItem)?.attributes?.informationData?.let { + val eventId = it.eventId + roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString())) } + } - override fun canSwipeModel(model: EpoxyModel<*>): Boolean { - return when (model) { - is MessageFileItem, - is MessageImageVideoItem, - is MessageTextItem -> { - return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED - } - else -> false + override fun canSwipeModel(model: EpoxyModel<*>): Boolean { + return when (model) { + is MessageFileItem, + is MessageImageVideoItem, + is MessageTextItem -> { + return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED } + else -> false } - }) - val touchHelper = ItemTouchHelper(swipeCallback) - touchHelper.attachToRecyclerView(recyclerView) - } + } + }) + val touchHelper = ItemTouchHelper(swipeCallback) + touchHelper.attachToRecyclerView(recyclerView) } + } - private fun updateJumpToBottomViewVisibility() { - debouncer.debounce("jump_to_bottom_visibility", 250, Runnable { - Timber.v("First visible: ${layoutManager.findFirstCompletelyVisibleItemPosition()}") - if (layoutManager.findFirstCompletelyVisibleItemPosition() != 0) { - jumpToBottomView.show() - } else { - jumpToBottomView.hide() - } - }) - } - - private fun setupComposer() { - val elevation = 6f - val backgroundDrawable = ColorDrawable(ThemeUtils.getColor(requireContext(), R.attr.riotx_background)) - Autocomplete.on(composerLayout.composerEditText) - .with(commandAutocompletePolicy) - .with(autocompleteCommandPresenter) - .with(elevation) - .with(backgroundDrawable) - .with(object : AutocompleteCallback { - override fun onPopupItemClicked(editable: Editable, item: Command): Boolean { - editable.clear() - editable - .append(item.command) - .append(" ") - return true - } - - override fun onPopupVisibilityChanged(shown: Boolean) { - } - }) - .build() - - autocompleteUserPresenter.callback = this - Autocomplete.on(composerLayout.composerEditText) - .with(CharPolicy('@', true)) - .with(autocompleteUserPresenter) - .with(elevation) - .with(backgroundDrawable) - .with(object : AutocompleteCallback { - override fun onPopupItemClicked(editable: Editable, item: User): Boolean { - // Detect last '@' and remove it - var startIndex = editable.lastIndexOf("@") - if (startIndex == -1) { - startIndex = 0 - } - - // Detect next word separator - var endIndex = editable.indexOf(" ", startIndex) - if (endIndex == -1) { - endIndex = editable.length - } - - // Replace the word by its completion - val displayName = item.displayName ?: item.userId - - // with a trailing space - editable.replace(startIndex, endIndex, "$displayName ") - - // Add the span - val user = session.getUser(item.userId) - val span = PillImageSpan(glideRequests, avatarRenderer, requireContext(), item.userId, user) - span.bind(composerLayout.composerEditText) - - editable.setSpan(span, startIndex, startIndex + displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) - - return true - } - - override fun onPopupVisibilityChanged(shown: Boolean) { - } - }) - .build() - - composerLayout.sendButton.setOnClickListener { - if (lockSendButton) { - Timber.w("Send button is locked") - return@setOnClickListener - } - val textMessage = composerLayout.composerEditText.text.toString() - if (textMessage.isNotBlank()) { - lockSendButton = true - roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage, vectorPreferences.isMarkdownEnabled())) - } - } - composerLayout.composerRelatedMessageCloseButton.setOnClickListener { - roomDetailViewModel.process(RoomDetailActions.ExitSpecialMode(composerLayout.composerEditText.text.toString())) - } - } - - private fun setupAttachmentButton() { - composerLayout.attachmentButton.setOnClickListener { - val intent = Intent(requireContext(), FilePickerActivity::class.java) - intent.putExtra(FilePickerActivity.CONFIGS, Configurations.Builder() - .setCheckPermission(true) - .setShowFiles(true) - .setShowAudios(true) - .setSkipZeroSizeFiles(true) - .build()) - startActivityForResult(intent, REQUEST_FILES_REQUEST_CODE) - /* - val items = ArrayList() - // Send file - items.add(DialogListItem.SendFile) - // Send voice - - if (vectorPreferences.isSendVoiceFeatureEnabled()) { - items.add(DialogListItem.SendVoice.INSTANCE) - } - - - // Send sticker - //items.add(DialogListItem.SendSticker) - // Camera - - //if (vectorPreferences.useNativeCamera()) { - items.add(DialogListItem.TakePhoto) - items.add(DialogListItem.TakeVideo) - //} else { - // items.add(DialogListItem.TakePhotoVideo.INSTANCE) - // } - val adapter = DialogSendItemAdapter(requireContext(), items) - AlertDialog.Builder(requireContext()) - .setAdapter(adapter) { _, position -> - onSendChoiceClicked(items[position]) - } - .setNegativeButton(R.string.cancel, null) - .show() - */ - } - } - - private fun setupInviteView() { - inviteView.callback = this - } - - private fun onSendChoiceClicked(dialogListItem: DialogListItem) { - Timber.v("On send choice clicked: $dialogListItem") - when (dialogListItem) { - is DialogListItem.SendFile -> { - // launchFileIntent - } - is DialogListItem.SendVoice -> { - //launchAudioRecorderIntent() - } - is DialogListItem.SendSticker -> { - //startStickerPickerActivity() - } - is DialogListItem.TakePhotoVideo -> - if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) { - // launchCamera() - } - is DialogListItem.TakePhoto -> - if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA)) { - openCamera(requireActivity(), CAMERA_VALUE_TITLE, TAKE_IMAGE_REQUEST_CODE) - } - is DialogListItem.TakeVideo -> - if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA)) { - // launchNativeVideoRecorder() - } - } - } - - private fun handleMediaIntent(data: Intent) { - val files: ArrayList = data.getParcelableArrayListExtra(FilePickerActivity.MEDIA_FILES) - roomDetailViewModel.process(RoomDetailActions.SendMedia(files)) - } - - private fun renderState(state: RoomDetailViewState) { - renderRoomSummary(state) - val summary = state.asyncRoomSummary() - val inviter = state.asyncInviter() - if (summary?.membership == Membership.JOIN) { - scrollOnHighlightedEventCallback.timeline = state.timeline - timelineEventController.update(state) - inviteView.visibility = View.GONE - val uid = session.myUserId - val meMember = session.getRoom(state.roomId)?.getRoomMember(uid) - avatarRenderer.render(meMember?.avatarUrl, uid, meMember?.displayName, composerLayout.composerAvatarImageView) - - } else if (summary?.membership == Membership.INVITE && inviter != null) { - inviteView.visibility = View.VISIBLE - inviteView.render(inviter, VectorInviteView.Mode.LARGE) - - // Intercept click event - inviteView.setOnClickListener { } - } else if (state.asyncInviter.complete) { - vectorBaseActivity.finish() - } - if (state.tombstoneEvent == null) { - composerLayout.visibility = View.VISIBLE - composerLayout.setRoomEncrypted(state.isEncrypted) - notificationAreaView.render(NotificationAreaView.State.Hidden) + private fun updateJumpToBottomViewVisibility() { + debouncer.debounce("jump_to_bottom_visibility", 250, Runnable { + Timber.v("First visible: ${layoutManager.findFirstCompletelyVisibleItemPosition()}") + if (layoutManager.findFirstCompletelyVisibleItemPosition() != 0) { + jumpToBottomView.show() } else { - composerLayout.visibility = View.GONE - notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent)) + jumpToBottomView.hide() } - jumpToReadMarkerView.render(state.showJumpToReadMarker, summary?.readMarkerId) - } + }) + } - private fun renderRoomSummary(state: RoomDetailViewState) { - state.asyncRoomSummary()?.let { - - if (it.membership.isLeft()) { - Timber.w("The room has been left") - activity?.finish() - } else { - roomToolbarTitleView.text = it.displayName - avatarRenderer.render(it, roomToolbarAvatarImageView) - roomToolbarSubtitleView.setTextOrHide(it.topic) - } - - jumpToBottomView.count = it.notificationCount - } - } - - private fun renderTextComposerState(state: TextComposerViewState) { - autocompleteUserPresenter.render(state.asyncUsers) - } - - private fun renderTombstoneEventHandling(async: Async) { - when (async) { - is Loading -> { - // TODO Better handling progress - vectorBaseActivity.showWaitingView() - vectorBaseActivity.waiting_view_status_text.visibility = View.VISIBLE - vectorBaseActivity.waiting_view_status_text.text = getString(R.string.joining_room) - } - is Success -> { - navigator.openRoom(vectorBaseActivity, async()) - vectorBaseActivity.finish() - } - is Fail -> { - vectorBaseActivity.hideWaitingView() - vectorBaseActivity.toast(errorFormatter.toHumanReadable(async.error)) - } - } - } - - private fun renderSendMessageResult(sendMessageResult: SendMessageResult) { - when (sendMessageResult) { - is SendMessageResult.MessageSent -> { - updateComposerText("") - } - is SendMessageResult.SlashCommandHandled -> { - sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) } - updateComposerText("") - } - is SendMessageResult.SlashCommandError -> { - displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) - } - is SendMessageResult.SlashCommandUnknown -> { - displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command)) - } - is SendMessageResult.SlashCommandResultOk -> { - updateComposerText("") - } - is SendMessageResult.SlashCommandResultError -> { - displayCommandError(sendMessageResult.throwable.localizedMessage) - } - is SendMessageResult.SlashCommandNotImplemented -> { - displayCommandError(getString(R.string.not_implemented)) - } - } - - lockSendButton = false - } - - private fun displayCommandError(message: String) { - AlertDialog.Builder(activity!!) - .setTitle(R.string.command_error) - .setMessage(message) - .setPositiveButton(R.string.ok, null) - .show() - } - -// TimelineEventController.Callback ************************************************************ - - override fun onUrlClicked(url: String): Boolean { - return permalinkHandler.launch(requireActivity(), url, object : NavigateToRoomInterceptor { - override fun navToRoom(roomId: String, eventId: String?): Boolean { - // Same room? - if (roomId == roomDetailArgs.roomId) { - // Navigation to same room - if (eventId == null) { - showSnackWithMessage(getString(R.string.navigate_to_room_when_already_in_the_room)) - } else { - // Highlight and scroll to this event - roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(eventId, true)) - } + private fun setupComposer() { + val elevation = 6f + val backgroundDrawable = ColorDrawable(ThemeUtils.getColor(requireContext(), R.attr.riotx_background)) + Autocomplete.on(composerLayout.composerEditText) + .with(commandAutocompletePolicy) + .with(autocompleteCommandPresenter) + .with(elevation) + .with(backgroundDrawable) + .with(object : AutocompleteCallback { + override fun onPopupItemClicked(editable: Editable, item: Command): Boolean { + editable.clear() + editable + .append(item.command) + .append(" ") return true } - // Not handled - return false - } - }) - } - - override fun onUrlLongClicked(url: String): Boolean { - if (url != getString(R.string.edited_suffix)) { - // Copy the url to the clipboard - copyToClipboard(requireContext(), url, true, R.string.link_copied_to_clipboard) - } - return true - } - - override fun onEventVisible(event: TimelineEvent) { - roomDetailViewModel.process(RoomDetailActions.TimelineEventTurnsVisible(event)) - } - - override fun onEventInvisible(event: TimelineEvent) { - roomDetailViewModel.process(RoomDetailActions.TimelineEventTurnsInvisible(event)) - } - - override fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) { - vectorBaseActivity.notImplemented("encrypted message click") - } - - override fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View) { - // TODO Use navigator - - val intent = ImageMediaViewerActivity.newIntent(vectorBaseActivity, mediaData, ViewCompat.getTransitionName(view)) - val pairs = ArrayList>() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - requireActivity().window.decorView.findViewById(android.R.id.statusBarBackground)?.let { - pairs.add(Pair(it, Window.STATUS_BAR_BACKGROUND_TRANSITION_NAME)) - } - requireActivity().window.decorView.findViewById(android.R.id.navigationBarBackground)?.let { - pairs.add(Pair(it, Window.NAVIGATION_BAR_BACKGROUND_TRANSITION_NAME)) - } - } - pairs.add(Pair(view, ViewCompat.getTransitionName(view) ?: "")) - pairs.add(Pair(roomToolbar, ViewCompat.getTransitionName(roomToolbar) ?: "")) - pairs.add(Pair(composerLayout, ViewCompat.getTransitionName(composerLayout) ?: "")) - - val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation( - requireActivity(), *pairs.toTypedArray()).toBundle() - startActivity(intent, bundle) - } - - override fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) { - // TODO Use navigator - val intent = VideoMediaViewerActivity.newIntent(vectorBaseActivity, mediaData) - startActivity(intent) - } - - override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) { - val action = RoomDetailActions.DownloadFile(eventId, messageFileContent) - // We need WRITE_EXTERNAL permission - if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_DOWNLOAD_FILE)) { - roomDetailViewModel.process(action) - } else { - roomDetailViewModel.pendingAction = action - } - } - - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { - if (allGranted(grantResults)) { - if (requestCode == PERMISSION_REQUEST_CODE_DOWNLOAD_FILE) { - val action = roomDetailViewModel.pendingAction - - if (action != null) { - roomDetailViewModel.pendingAction = null - roomDetailViewModel.process(action) + override fun onPopupVisibilityChanged(shown: Boolean) { } - } + }) + .build() + + autocompleteUserPresenter.callback = this + Autocomplete.on(composerLayout.composerEditText) + .with(CharPolicy('@', true)) + .with(autocompleteUserPresenter) + .with(elevation) + .with(backgroundDrawable) + .with(object : AutocompleteCallback { + override fun onPopupItemClicked(editable: Editable, item: User): Boolean { + // Detect last '@' and remove it + var startIndex = editable.lastIndexOf("@") + if (startIndex == -1) { + startIndex = 0 + } + + // Detect next word separator + var endIndex = editable.indexOf(" ", startIndex) + if (endIndex == -1) { + endIndex = editable.length + } + + // Replace the word by its completion + val displayName = item.displayName ?: item.userId + + // with a trailing space + editable.replace(startIndex, endIndex, "$displayName ") + + // Add the span + val user = session.getUser(item.userId) + val span = PillImageSpan(glideRequests, avatarRenderer, requireContext(), item.userId, user) + span.bind(composerLayout.composerEditText) + + editable.setSpan(span, startIndex, startIndex + displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + + return true + } + + override fun onPopupVisibilityChanged(shown: Boolean) { + } + }) + .build() + + composerLayout.sendButton.setOnClickListener { + if (lockSendButton) { + Timber.w("Send button is locked") + return@setOnClickListener + } + val textMessage = composerLayout.composerEditText.text.toString() + if (textMessage.isNotBlank()) { + lockSendButton = true + roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage, vectorPreferences.isMarkdownEnabled())) } } - - override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) { - vectorBaseActivity.notImplemented("open audio file") + composerLayout.composerRelatedMessageCloseButton.setOnClickListener { + roomDetailViewModel.process(RoomDetailActions.ExitSpecialMode(composerLayout.composerEditText.text.toString())) } + } - override fun onLoadMore(direction: Timeline.Direction) { - roomDetailViewModel.process(RoomDetailActions.LoadMoreTimelineEvents(direction)) + private fun setupAttachmentButton() { + composerLayout.attachmentButton.setOnClickListener { + val intent = Intent(requireContext(), FilePickerActivity::class.java) + intent.putExtra(FilePickerActivity.CONFIGS, Configurations.Builder() + .setCheckPermission(true) + .setShowFiles(true) + .setShowAudios(true) + .setSkipZeroSizeFiles(true) + .build()) + startActivityForResult(intent, REQUEST_FILES_REQUEST_CODE) + /* + val items = ArrayList() + // Send file + items.add(DialogListItem.SendFile) + // Send voice + + if (vectorPreferences.isSendVoiceFeatureEnabled()) { + items.add(DialogListItem.SendVoice.INSTANCE) + } + + + // Send sticker + //items.add(DialogListItem.SendSticker) + // Camera + + //if (vectorPreferences.useNativeCamera()) { + items.add(DialogListItem.TakePhoto) + items.add(DialogListItem.TakeVideo) + //} else { + // items.add(DialogListItem.TakePhotoVideo.INSTANCE) + // } + val adapter = DialogSendItemAdapter(requireContext(), items) + AlertDialog.Builder(requireContext()) + .setAdapter(adapter) { _, position -> + onSendChoiceClicked(items[position]) + } + .setNegativeButton(R.string.cancel, null) + .show() + */ } + } - override fun onEventCellClicked(informationData: MessageInformationData, messageContent: MessageContent?, view: View) { + private fun setupInviteView() { + inviteView.callback = this + } + private fun onSendChoiceClicked(dialogListItem: DialogListItem) { + Timber.v("On send choice clicked: $dialogListItem") + when (dialogListItem) { + is DialogListItem.SendFile -> { + // launchFileIntent + } + is DialogListItem.SendVoice -> { + //launchAudioRecorderIntent() + } + is DialogListItem.SendSticker -> { + //startStickerPickerActivity() + } + is DialogListItem.TakePhotoVideo -> + if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_CAMERA)) { + // launchCamera() + } + is DialogListItem.TakePhoto -> + if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA)) { + openCamera(requireActivity(), CAMERA_VALUE_TITLE, TAKE_IMAGE_REQUEST_CODE) + } + is DialogListItem.TakeVideo -> + if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA)) { + // launchNativeVideoRecorder() + } } + } - override fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent?, view: View): Boolean { - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - val roomId = roomDetailArgs.roomId + private fun handleMediaIntent(data: Intent) { + val files: ArrayList = data.getParcelableArrayListExtra(FilePickerActivity.MEDIA_FILES) + roomDetailViewModel.process(RoomDetailActions.SendMedia(files)) + } - this.view?.hideKeyboard() - MessageActionsBottomSheet - .newInstance(roomId, informationData) - .show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS") - return true + private fun renderState(state: RoomDetailViewState) { + renderRoomSummary(state) + val summary = state.asyncRoomSummary() + val inviter = state.asyncInviter() + if (summary?.membership == Membership.JOIN) { + scrollOnHighlightedEventCallback.timeline = state.timeline + timelineEventController.update(state) + inviteView.visibility = View.GONE + val uid = session.myUserId + val meMember = session.getRoom(state.roomId)?.getRoomMember(uid) + avatarRenderer.render(meMember?.avatarUrl, uid, meMember?.displayName, composerLayout.composerAvatarImageView) + + } else if (summary?.membership == Membership.INVITE && inviter != null) { + inviteView.visibility = View.VISIBLE + inviteView.render(inviter, VectorInviteView.Mode.LARGE) + + // Intercept click event + inviteView.setOnClickListener { } + } else if (state.asyncInviter.complete) { + vectorBaseActivity.finish() } - - override fun onAvatarClicked(informationData: MessageInformationData) { - vectorBaseActivity.notImplemented("Click on user avatar") + if (state.tombstoneEvent == null) { + composerLayout.visibility = View.VISIBLE + composerLayout.setRoomEncrypted(state.isEncrypted) + notificationAreaView.render(NotificationAreaView.State.Hidden) + } else { + composerLayout.visibility = View.GONE + notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent)) } + jumpToReadMarkerView.render(state.showJumpToReadMarker, summary?.readMarkerId) + } - @SuppressLint("SetTextI18n") - override fun onMemberNameClicked(informationData: MessageInformationData) { - insertUserDisplayNameInTextEditor(informationData.memberName?.toString()) - } + private fun renderRoomSummary(state: RoomDetailViewState) { + state.asyncRoomSummary()?.let { - override fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean) { - if (on) { - //we should test the current real state of reaction on this event - roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, informationData.eventId)) + if (it.membership.isLeft()) { + Timber.w("The room has been left") + activity?.finish() } else { - //I need to redact a reaction - roomDetailViewModel.process(RoomDetailActions.UndoReaction(informationData.eventId, reaction)) + roomToolbarTitleView.text = it.displayName + avatarRenderer.render(it, roomToolbarAvatarImageView) + roomToolbarSubtitleView.setTextOrHide(it.topic) + } + jumpToBottomView.count = it.notificationCount + jumpToBottomView.drawBadge = it.hasUnreadMessages + } + } + + private fun renderTextComposerState(state: TextComposerViewState) { + autocompleteUserPresenter.render(state.asyncUsers) + } + + private fun renderTombstoneEventHandling(async: Async) { + when (async) { + is Loading -> { + // TODO Better handling progress + vectorBaseActivity.showWaitingView() + vectorBaseActivity.waiting_view_status_text.visibility = View.VISIBLE + vectorBaseActivity.waiting_view_status_text.text = getString(R.string.joining_room) + } + is Success -> { + navigator.openRoom(vectorBaseActivity, async()) + vectorBaseActivity.finish() + } + is Fail -> { + vectorBaseActivity.hideWaitingView() + vectorBaseActivity.toast(errorFormatter.toHumanReadable(async.error)) + } + } + } + + private fun renderSendMessageResult(sendMessageResult: SendMessageResult) { + when (sendMessageResult) { + is SendMessageResult.MessageSent -> { + updateComposerText("") + } + is SendMessageResult.SlashCommandHandled -> { + sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) } + updateComposerText("") + } + is SendMessageResult.SlashCommandError -> { + displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) + } + is SendMessageResult.SlashCommandUnknown -> { + displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command)) + } + is SendMessageResult.SlashCommandResultOk -> { + updateComposerText("") + } + is SendMessageResult.SlashCommandResultError -> { + displayCommandError(sendMessageResult.throwable.localizedMessage) + } + is SendMessageResult.SlashCommandNotImplemented -> { + displayCommandError(getString(R.string.not_implemented)) } } - override fun onLongClickOnReactionPill(informationData: MessageInformationData, reaction: String) { - ViewReactionBottomSheet.newInstance(roomDetailArgs.roomId, informationData) - .show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS") - } + lockSendButton = false + } - override fun onEditedDecorationClicked(informationData: MessageInformationData) { - ViewEditHistoryBottomSheet.newInstance(roomDetailArgs.roomId, informationData) - .show(requireActivity().supportFragmentManager, "DISPLAY_EDITS") - } + private fun displayCommandError(message: String) { + AlertDialog.Builder(activity!!) + .setTitle(R.string.command_error) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .show() + } - override fun onRoomCreateLinkClicked(url: String) { - permalinkHandler.launch(requireContext(), url, object : NavigateToRoomInterceptor { - override fun navToRoom(roomId: String, eventId: String?): Boolean { - requireActivity().finish() - return false +// TimelineEventController.Callback ************************************************************ + + override fun onUrlClicked(url: String): Boolean { + return permalinkHandler.launch(requireActivity(), url, object : NavigateToRoomInterceptor { + override fun navToRoom(roomId: String, eventId: String?): Boolean { + // Same room? + if (roomId == roomDetailArgs.roomId) { + // Navigation to same room + if (eventId == null) { + showSnackWithMessage(getString(R.string.navigate_to_room_when_already_in_the_room)) + } else { + // Highlight and scroll to this event + roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(eventId, true)) + } + return true } - }) - } - override fun onReadReceiptsClicked(readReceipts: List) { - DisplayReadReceiptsBottomSheet.newInstance(readReceipts) - .show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS") - } + // Not handled + return false + } + }) + } - override fun onReadMarkerLongDisplayed() = withState(roomDetailViewModel) { state -> - val firstVisibleItem = layoutManager.findFirstVisibleItemPosition() - val nextReadMarkerId = timelineEventController.searchEventIdAtPosition(firstVisibleItem) - if (nextReadMarkerId != null) { - roomDetailViewModel.process(RoomDetailActions.SetReadMarkerAction(nextReadMarkerId)) + override fun onUrlLongClicked(url: String): Boolean { + if (url != getString(R.string.edited_suffix)) { + // Copy the url to the clipboard + copyToClipboard(requireContext(), url, true, R.string.link_copied_to_clipboard) + } + return true + } + + override fun onEventVisible(event: TimelineEvent) { + roomDetailViewModel.process(RoomDetailActions.TimelineEventTurnsVisible(event)) + } + + override fun onEventInvisible(event: TimelineEvent) { + roomDetailViewModel.process(RoomDetailActions.TimelineEventTurnsInvisible(event)) + } + + override fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) { + vectorBaseActivity.notImplemented("encrypted message click") + } + + override fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View) { + // TODO Use navigator + + val intent = ImageMediaViewerActivity.newIntent(vectorBaseActivity, mediaData, ViewCompat.getTransitionName(view)) + val pairs = ArrayList>() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + requireActivity().window.decorView.findViewById(android.R.id.statusBarBackground)?.let { + pairs.add(Pair(it, Window.STATUS_BAR_BACKGROUND_TRANSITION_NAME)) + } + requireActivity().window.decorView.findViewById(android.R.id.navigationBarBackground)?.let { + pairs.add(Pair(it, Window.NAVIGATION_BAR_BACKGROUND_TRANSITION_NAME)) } } + pairs.add(Pair(view, ViewCompat.getTransitionName(view) ?: "")) + pairs.add(Pair(roomToolbar, ViewCompat.getTransitionName(roomToolbar) ?: "")) + pairs.add(Pair(composerLayout, ViewCompat.getTransitionName(composerLayout) ?: "")) - // AutocompleteUserPresenter.Callback + val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation( + requireActivity(), *pairs.toTypedArray()).toBundle() + startActivity(intent, bundle) + } - override fun onQueryUsers(query: CharSequence?) { - textComposerViewModel.process(TextComposerActions.QueryUsers(query)) + override fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) { + // TODO Use navigator + val intent = VideoMediaViewerActivity.newIntent(vectorBaseActivity, mediaData) + startActivity(intent) + } + + override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) { + val action = RoomDetailActions.DownloadFile(eventId, messageFileContent) + // We need WRITE_EXTERNAL permission + if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_DOWNLOAD_FILE)) { + roomDetailViewModel.process(action) + } else { + roomDetailViewModel.pendingAction = action } + } - private fun handleActions(action: SimpleAction) { - when (action) { - is SimpleAction.AddReaction -> { - startActivityForResult(EmojiReactionPickerActivity.intent(requireContext(), action.eventId), REACTION_SELECT_REQUEST_CODE) + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (allGranted(grantResults)) { + if (requestCode == PERMISSION_REQUEST_CODE_DOWNLOAD_FILE) { + val action = roomDetailViewModel.pendingAction + + if (action != null) { + roomDetailViewModel.pendingAction = null + roomDetailViewModel.process(action) } - is SimpleAction.ViewReactions -> { - ViewReactionBottomSheet.newInstance(roomDetailArgs.roomId, action.messageInformationData) - .show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS") - } - is SimpleAction.Copy -> { - //I need info about the current selected message :/ - copyToClipboard(requireContext(), action.content, false) - val msg = requireContext().getString(R.string.copied_to_clipboard) - showSnackWithMessage(msg, Snackbar.LENGTH_SHORT) - } - is SimpleAction.Delete -> { - roomDetailViewModel.process(RoomDetailActions.RedactAction(action.eventId, context?.getString(R.string.event_redacted_by_user_reason))) - } - is SimpleAction.Share -> { - //TODO current data communication is too limited - //Need to now the media type - //TODO bad, just POC - BigImageViewer.imageLoader().loadImage( - action.hashCode(), - Uri.parse(action.imageUrl), - object : ImageLoader.Callback { - override fun onFinish() {} + } + } + } - override fun onSuccess(image: File?) { - if (image != null) - shareMedia(requireContext(), image, "image/*") - } + override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) { + vectorBaseActivity.notImplemented("open audio file") + } - override fun onFail(error: Exception?) {} + override fun onLoadMore(direction: Timeline.Direction) { + roomDetailViewModel.process(RoomDetailActions.LoadMoreTimelineEvents(direction)) + } - override fun onCacheHit(imageType: Int, image: File?) {} + override fun onEventCellClicked(informationData: MessageInformationData, messageContent: MessageContent?, view: View) { - override fun onCacheMiss(imageType: Int, image: File?) {} + } - override fun onProgress(progress: Int) {} + override fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent?, view: View): Boolean { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + val roomId = roomDetailArgs.roomId - override fun onStart() {} + this.view?.hideKeyboard() + MessageActionsBottomSheet + .newInstance(roomId, informationData) + .show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS") + return true + } + override fun onAvatarClicked(informationData: MessageInformationData) { + vectorBaseActivity.notImplemented("Click on user avatar") + } + + @SuppressLint("SetTextI18n") + override fun onMemberNameClicked(informationData: MessageInformationData) { + insertUserDisplayNameInTextEditor(informationData.memberName?.toString()) + } + + override fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean) { + if (on) { + //we should test the current real state of reaction on this event + roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, informationData.eventId)) + } else { + //I need to redact a reaction + roomDetailViewModel.process(RoomDetailActions.UndoReaction(informationData.eventId, reaction)) + } + } + + override fun onLongClickOnReactionPill(informationData: MessageInformationData, reaction: String) { + ViewReactionBottomSheet.newInstance(roomDetailArgs.roomId, informationData) + .show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS") + } + + override fun onEditedDecorationClicked(informationData: MessageInformationData) { + ViewEditHistoryBottomSheet.newInstance(roomDetailArgs.roomId, informationData) + .show(requireActivity().supportFragmentManager, "DISPLAY_EDITS") + } + + override fun onRoomCreateLinkClicked(url: String) { + permalinkHandler.launch(requireContext(), url, object : NavigateToRoomInterceptor { + override fun navToRoom(roomId: String, eventId: String?): Boolean { + requireActivity().finish() + return false + } + }) + } + + override fun onReadReceiptsClicked(readReceipts: List) { + DisplayReadReceiptsBottomSheet.newInstance(readReceipts) + .show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS") + } + + override fun onReadMarkerLongDisplayed() = withState(roomDetailViewModel) { state -> + val firstVisibleItem = layoutManager.findFirstVisibleItemPosition() + val nextReadMarkerId = timelineEventController.searchEventIdAtPosition(firstVisibleItem) + if (nextReadMarkerId != null) { + roomDetailViewModel.process(RoomDetailActions.SetReadMarkerAction(nextReadMarkerId)) + } + } + + // AutocompleteUserPresenter.Callback + + override fun onQueryUsers(query: CharSequence?) { + textComposerViewModel.process(TextComposerActions.QueryUsers(query)) + } + + private fun handleActions(action: SimpleAction) { + when (action) { + is SimpleAction.AddReaction -> { + startActivityForResult(EmojiReactionPickerActivity.intent(requireContext(), action.eventId), REACTION_SELECT_REQUEST_CODE) + } + is SimpleAction.ViewReactions -> { + ViewReactionBottomSheet.newInstance(roomDetailArgs.roomId, action.messageInformationData) + .show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS") + } + is SimpleAction.Copy -> { + //I need info about the current selected message :/ + copyToClipboard(requireContext(), action.content, false) + val msg = requireContext().getString(R.string.copied_to_clipboard) + showSnackWithMessage(msg, Snackbar.LENGTH_SHORT) + } + is SimpleAction.Delete -> { + roomDetailViewModel.process(RoomDetailActions.RedactAction(action.eventId, context?.getString(R.string.event_redacted_by_user_reason))) + } + is SimpleAction.Share -> { + //TODO current data communication is too limited + //Need to now the media type + //TODO bad, just POC + BigImageViewer.imageLoader().loadImage( + action.hashCode(), + Uri.parse(action.imageUrl), + object : ImageLoader.Callback { + override fun onFinish() {} + + override fun onSuccess(image: File?) { + if (image != null) + shareMedia(requireContext(), image, "image/*") } - ) - } - is SimpleAction.ViewEditHistory -> { - onEditedDecorationClicked(action.messageInformationData) - } - is SimpleAction.ViewSource -> { - val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_event_content, null) - view.findViewById(R.id.event_content_text_view)?.let { - it.text = action.content - } - AlertDialog.Builder(requireActivity()) - .setView(view) - .setPositiveButton(R.string.ok, null) - .show() - } - is SimpleAction.ViewDecryptedSource -> { - val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_event_content, null) - view.findViewById(R.id.event_content_text_view)?.let { - it.text = action.content - } + override fun onFail(error: Exception?) {} - AlertDialog.Builder(requireActivity()) - .setView(view) - .setPositiveButton(R.string.ok, null) - .show() - } - is SimpleAction.QuickReact -> { - //eventId,ClickedOn,Add - roomDetailViewModel.process(RoomDetailActions.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add)) - } - is SimpleAction.Edit -> { - roomDetailViewModel.process(RoomDetailActions.EnterEditMode(action.eventId, composerLayout.composerEditText.text.toString())) - } - is SimpleAction.Quote -> { - roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(action.eventId, composerLayout.composerEditText.text.toString())) - } - is SimpleAction.Reply -> { - roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(action.eventId, composerLayout.composerEditText.text.toString())) - } - is SimpleAction.CopyPermalink -> { - val permalink = PermalinkFactory.createPermalink(roomDetailArgs.roomId, action.eventId) - copyToClipboard(requireContext(), permalink, false) - showSnackWithMessage(requireContext().getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT) + override fun onCacheHit(imageType: Int, image: File?) {} + override fun onCacheMiss(imageType: Int, image: File?) {} + + override fun onProgress(progress: Int) {} + + override fun onStart() {} + + } + ) + } + is SimpleAction.ViewEditHistory -> { + onEditedDecorationClicked(action.messageInformationData) + } + is SimpleAction.ViewSource -> { + val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_event_content, null) + view.findViewById(R.id.event_content_text_view)?.let { + it.text = action.content } - is SimpleAction.Resend -> { - roomDetailViewModel.process(RoomDetailActions.ResendMessage(action.eventId)) - } - is SimpleAction.Remove -> { - roomDetailViewModel.process(RoomDetailActions.RemoveFailedEcho(action.eventId)) - } - else -> { - Toast.makeText(context, "Action $action is not implemented yet", Toast.LENGTH_LONG).show() + + AlertDialog.Builder(requireActivity()) + .setView(view) + .setPositiveButton(R.string.ok, null) + .show() + } + is SimpleAction.ViewDecryptedSource -> { + val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_event_content, null) + view.findViewById(R.id.event_content_text_view)?.let { + it.text = action.content } + + AlertDialog.Builder(requireActivity()) + .setView(view) + .setPositiveButton(R.string.ok, null) + .show() + } + is SimpleAction.QuickReact -> { + //eventId,ClickedOn,Add + roomDetailViewModel.process(RoomDetailActions.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add)) + } + is SimpleAction.Edit -> { + roomDetailViewModel.process(RoomDetailActions.EnterEditMode(action.eventId, composerLayout.composerEditText.text.toString())) + } + is SimpleAction.Quote -> { + roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(action.eventId, composerLayout.composerEditText.text.toString())) + } + is SimpleAction.Reply -> { + roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(action.eventId, composerLayout.composerEditText.text.toString())) + } + is SimpleAction.CopyPermalink -> { + val permalink = PermalinkFactory.createPermalink(roomDetailArgs.roomId, action.eventId) + copyToClipboard(requireContext(), permalink, false) + showSnackWithMessage(requireContext().getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT) + + } + is SimpleAction.Resend -> { + roomDetailViewModel.process(RoomDetailActions.ResendMessage(action.eventId)) + } + is SimpleAction.Remove -> { + roomDetailViewModel.process(RoomDetailActions.RemoveFailedEcho(action.eventId)) + } + else -> { + Toast.makeText(context, "Action $action is not implemented yet", Toast.LENGTH_LONG).show() } } + } //utils - /** - * Insert an user displayname in the message editor. - * - * @param text the text to insert. - */ + /** + * Insert an user displayname in the message editor. + * + * @param text the text to insert. + */ //TODO legacy, refactor - private fun insertUserDisplayNameInTextEditor(text: String?) { - //TODO move logic outside of fragment - if (null != text) { + private fun insertUserDisplayNameInTextEditor(text: String?) { + //TODO move logic outside of fragment + if (null != text) { // var vibrate = false - val myDisplayName = session.getUser(session.myUserId)?.displayName - if (TextUtils.equals(myDisplayName, text)) { - // current user - if (TextUtils.isEmpty(composerLayout.composerEditText.text)) { - composerLayout.composerEditText.append(Command.EMOTE.command + " ") - composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length) + val myDisplayName = session.getUser(session.myUserId)?.displayName + if (TextUtils.equals(myDisplayName, text)) { + // current user + if (TextUtils.isEmpty(composerLayout.composerEditText.text)) { + composerLayout.composerEditText.append(Command.EMOTE.command + " ") + composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length) // vibrate = true + } + } else { + // another user + if (TextUtils.isEmpty(composerLayout.composerEditText.text)) { + // Ensure displayName will not be interpreted as a Slash command + if (text.startsWith("/")) { + composerLayout.composerEditText.append("\\") } + composerLayout.composerEditText.append(sanitizeDisplayname(text)!! + ": ") } else { - // another user - if (TextUtils.isEmpty(composerLayout.composerEditText.text)) { - // Ensure displayName will not be interpreted as a Slash command - if (text.startsWith("/")) { - composerLayout.composerEditText.append("\\") - } - composerLayout.composerEditText.append(sanitizeDisplayname(text)!! + ": ") - } else { - composerLayout.composerEditText.text.insert(composerLayout.composerEditText.selectionStart, sanitizeDisplayname(text)!! + " ") - } + composerLayout.composerEditText.text.insert(composerLayout.composerEditText.selectionStart, sanitizeDisplayname(text)!! + " ") + } // vibrate = true - } + } // if (vibrate && vectorPreferences.vibrateWhenMentioning()) { // val v= context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator @@ -1122,44 +1122,44 @@ class RoomDetailFragment : // v.vibrate(100) // } // } - focusComposerAndShowKeyboard() - } + focusComposerAndShowKeyboard() } + } - private fun focusComposerAndShowKeyboard() { - composerLayout.composerEditText.requestFocus() - val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager - imm?.showSoftInput(composerLayout.composerEditText, InputMethodManager.SHOW_IMPLICIT) - } + private fun focusComposerAndShowKeyboard() { + composerLayout.composerEditText.requestFocus() + val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + imm?.showSoftInput(composerLayout.composerEditText, InputMethodManager.SHOW_IMPLICIT) + } - private fun showSnackWithMessage(message: String, duration: Int = Snackbar.LENGTH_SHORT) { - val snack = Snackbar.make(view!!, message, duration) - snack.view.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.notification_accent_color)) - snack.show() - } + private fun showSnackWithMessage(message: String, duration: Int = Snackbar.LENGTH_SHORT) { + val snack = Snackbar.make(view!!, message, duration) + snack.view.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.notification_accent_color)) + snack.show() + } - // VectorInviteView.Callback + // VectorInviteView.Callback - override fun onAcceptInvite() { - notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) - roomDetailViewModel.process(RoomDetailActions.AcceptInvite) - } + override fun onAcceptInvite() { + notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) + roomDetailViewModel.process(RoomDetailActions.AcceptInvite) + } - override fun onRejectInvite() { - notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) - roomDetailViewModel.process(RoomDetailActions.RejectInvite) - } + override fun onRejectInvite() { + notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) + roomDetailViewModel.process(RoomDetailActions.RejectInvite) + } // JumpToReadMarkerView.Callback - override fun onJumpToReadMarkerClicked(readMarkerId: String) { - roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(readMarkerId, false)) - } - - override fun onClearReadMarkerClicked() { - roomDetailViewModel.process(RoomDetailActions.MarkAllAsRead) - } - - + override fun onJumpToReadMarkerClicked(readMarkerId: String) { + roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(readMarkerId, false)) } + + override fun onClearReadMarkerClicked() { + roomDetailViewModel.process(RoomDetailActions.MarkAllAsRead) + } + + +} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt index 62d80408d2..92e38c112b 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt @@ -20,9 +20,15 @@ import androidx.recyclerview.widget.LinearLayoutManager import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.riotx.core.platform.DefaultListUpdateCallback import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch import timber.log.Timber import java.util.concurrent.atomic.AtomicReference +/** + * This handles scrolling to an event which wasn't yet loaded when scheduled. + */ class ScrollOnHighlightedEventCallback(private val layoutManager: LinearLayoutManager, private val timelineEventController: TimelineEventController) : DefaultListUpdateCallback { @@ -30,7 +36,15 @@ class ScrollOnHighlightedEventCallback(private val layoutManager: LinearLayoutMa var timeline: Timeline? = null + override fun onInserted(position: Int, count: Int) { + scrollIfNeeded() + } + override fun onChanged(position: Int, count: Int, tag: Any?) { + scrollIfNeeded() + } + + private fun scrollIfNeeded() { val eventId = scheduledEventId.get() ?: return val nonNullTimeline = timeline ?: return val correctedEventId = nonNullTimeline.getFirstDisplayableEventId(eventId) From b6e18e4a8f0d5132fe2c6f1972836514c5adfc22 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 20 Sep 2019 18:34:58 +0200 Subject: [PATCH 13/22] Timeline: add badge also when unread without notif --- .../core/platform/BadgeFloatingActionButton.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/vector/src/main/java/im/vector/riotx/core/platform/BadgeFloatingActionButton.kt b/vector/src/main/java/im/vector/riotx/core/platform/BadgeFloatingActionButton.kt index 545fd9409f..4de26cd657 100644 --- a/vector/src/main/java/im/vector/riotx/core/platform/BadgeFloatingActionButton.kt +++ b/vector/src/main/java/im/vector/riotx/core/platform/BadgeFloatingActionButton.kt @@ -114,6 +114,14 @@ class BadgeFloatingActionButton @JvmOverloads constructor( } } + var drawBadge: Boolean = false + set(value) { + if (field != value) { + field = value + invalidate() + } + } + init { countStr = countStr(count) textPaint.getTextBounds(countStr, 0, countStr.length, counterTextBounds) @@ -139,10 +147,10 @@ class BadgeFloatingActionButton @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - - if (count > 0) { + if (count > 0 || drawBadge) { canvas.drawCircle(counterBounds.centerX(), counterBounds.centerY(), counterBounds.width() / 2f, tintPaint) - + } + if (count > 0) { val textX = counterBounds.centerX() - counterTextBounds.width() / 2f - counterTextBounds.left val textY = counterBounds.centerY() + counterTextBounds.height() / 2f - counterTextBounds.bottom canvas.drawText(countStr, textX, textY, textPaint) From f6d34ec7fdff94d1fa5c414f2dee9ae502dd3f44 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 23 Sep 2019 17:43:37 +0200 Subject: [PATCH 14/22] Timeline: update state management --- .../database/mapper/TimelineEventMapper.kt | 2 +- .../session/room/timeline/DefaultTimeline.kt | 186 ++++++++---------- .../home/room/detail/RoomDetailFragment.kt | 2 +- .../room/detail/ScrollOnNewMessageCallback.kt | 2 +- 4 files changed, 90 insertions(+), 102 deletions(-) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt index 0e9f13155e..5bd6f99b3a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/TimelineEventMapper.kt @@ -46,7 +46,7 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS readReceipts = readReceipts?.sortedByDescending { it.originServerTs } ?: emptyList(), - hasReadMarker = timelineEventEntity.readMarker?.eventId?.isEmpty() == false + hasReadMarker = timelineEventEntity.readMarker?.eventId?.isNotEmpty() == true ) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index f22be74f70..9147b922cb 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -18,7 +18,6 @@ package im.vector.matrix.android.internal.session.room.timeline import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.crypto.CryptoService -import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.room.model.ReadReceipt import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.timeline.Timeline @@ -37,8 +36,6 @@ import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields import im.vector.matrix.android.internal.database.query.FilterContent import im.vector.matrix.android.internal.database.query.findAllInRoomWithSendStates -import im.vector.matrix.android.internal.database.query.findIncludingEvent -import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.whereInRoom import im.vector.matrix.android.internal.task.TaskConstraints @@ -109,8 +106,8 @@ internal class DefaultTimeline( private var nextDisplayIndex: Int? = null private val builtEvents = Collections.synchronizedList(ArrayList()) private val builtEventsIdMap = Collections.synchronizedMap(HashMap()) - private val backwardsPaginationState = AtomicReference(PaginationState()) - private val forwardsPaginationState = AtomicReference(PaginationState()) + private val backwardsState = AtomicReference(State()) + private val forwardsState = AtomicReference(State()) private val timelineID = UUID.randomUUID().toString() @@ -126,43 +123,11 @@ internal class DefaultTimeline( if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL) { handleInitialLoad() } else { - // If changeSet has deletion we are having a gap, so we clear everything - if (changeSet.deletionRanges.isNotEmpty()) { - clearAllValues() - } - changeSet.insertionRanges.forEach { range -> - val (startDisplayIndex, direction) = if (range.startIndex == 0) { - Pair(filteredEvents[range.length - 1]!!.root!!.displayIndex, Timeline.Direction.FORWARDS) - } else { - Pair(filteredEvents[range.startIndex]!!.root!!.displayIndex, Timeline.Direction.BACKWARDS) - } - val state = getPaginationState(direction) - if (state.isPaginating) { - // We are getting new items from pagination - val shouldPostSnapshot = paginateInternal(startDisplayIndex, direction, state.requestedCount) - if (shouldPostSnapshot) { - postSnapshot() - } - } else { - // We are getting new items from sync - buildTimelineEvents(startDisplayIndex, direction, range.length.toLong()) - postSnapshot() - } - } - - var hasChanged = false - changeSet.changes.forEach { index -> - val eventEntity = results[index] - eventEntity?.eventId?.let { eventId -> - hasChanged = rebuildEvent(eventId) { - buildTimelineEvent(eventEntity) - } || hasChanged - } - } - if (hasChanged) postSnapshot() + handleUpdates(changeSet) } } + private val relationsListener = OrderedRealmCollectionChangeListener> { collection, changeSet -> var hasChange = false @@ -215,7 +180,6 @@ internal class DefaultTimeline( backgroundRealm.set(realm) clearUnlinkedEvents(realm) - roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()?.also { it.sendingTimelineEvents.addChangeListener { _ -> postSnapshot() @@ -356,29 +320,29 @@ internal class DefaultTimeline( } ?: false } - private fun hasMoreInCache(direction: Timeline.Direction): Boolean { - return Realm.getInstance(realmConfiguration).use { localRealm -> - val timelineEventEntity = buildEventQuery(localRealm).findFirst(direction) - ?: return false - if (direction == Timeline.Direction.FORWARDS) { - val firstEvent = builtEvents.firstOrNull() ?: return true - firstEvent.displayIndex < timelineEventEntity.root!!.displayIndex - } else { - val lastEvent = builtEvents.lastOrNull() ?: return true - lastEvent.displayIndex > timelineEventEntity.root!!.displayIndex - } - } - } + private fun hasMoreInCache(direction: Timeline.Direction) = getState(direction).hasMoreInCache - private fun hasReachedEnd(direction: Timeline.Direction): Boolean { - return Realm.getInstance(realmConfiguration).use { localRealm -> - val currentChunk = findCurrentChunk(localRealm) ?: return false - if (direction == Timeline.Direction.FORWARDS) { - currentChunk.isLastForward - } else { - val eventEntity = buildEventQuery(localRealm).findFirst(direction) - currentChunk.isLastBackward || eventEntity?.root?.type == EventType.STATE_ROOM_CREATE - } + private fun hasReachedEnd(direction: Timeline.Direction) = getState(direction).hasReachedEnd + + private fun updateLoadingStates(results: RealmResults) { + val lastCacheEvent = results.lastOrNull() + val lastBuiltEvent = builtEvents.lastOrNull() + val firstCacheEvent = results.firstOrNull() + val firstBuiltEvent = builtEvents.firstOrNull() + val chunkEntity = getLiveChunk() + + updateState(Timeline.Direction.FORWARDS) { + it.copy( + hasMoreInCache = firstBuiltEvent == null || firstBuiltEvent.displayIndex < firstCacheEvent?.root?.displayIndex ?: Int.MIN_VALUE, + hasReachedEnd = chunkEntity?.isLastForward ?: false + ) + } + + updateState(Timeline.Direction.BACKWARDS) { + it.copy( + hasMoreInCache = lastBuiltEvent == null || lastBuiltEvent.displayIndex > lastCacheEvent?.root?.displayIndex ?: Int.MAX_VALUE, + hasReachedEnd = chunkEntity?.isLastBackward ?: false + ) } } @@ -391,16 +355,16 @@ internal class DefaultTimeline( direction: Timeline.Direction, count: Int, strict: Boolean = false): Boolean { - updatePaginationState(direction) { it.copy(requestedCount = count, isPaginating = true) } + updateState(direction) { it.copy(requestedPaginationCount = count, isPaginating = true) } val builtCount = buildTimelineEvents(startDisplayIndex, direction, count.toLong(), strict) val shouldFetchMore = builtCount < count && !hasReachedEnd(direction) if (shouldFetchMore) { val newRequestedCount = count - builtCount - updatePaginationState(direction) { it.copy(requestedCount = newRequestedCount) } + updateState(direction) { it.copy(requestedPaginationCount = newRequestedCount) } val fetchingCount = max(MIN_FETCHING_COUNT, newRequestedCount) executePaginationTask(direction, fetchingCount) } else { - updatePaginationState(direction) { it.copy(isPaginating = false, requestedCount = 0) } + updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } } return !shouldFetchMore @@ -412,7 +376,7 @@ internal class DefaultTimeline( private fun buildSendingEvents(): List { val sendingEvents = ArrayList() - if (hasReachedEnd(Timeline.Direction.FORWARDS)) { + if (hasReachedEnd(Timeline.Direction.FORWARDS) && !hasMoreInCache(Timeline.Direction.FORWARDS)) { roomEntity?.sendingTimelineEvents ?.where() ?.filterEventsWithSettings() @@ -425,20 +389,20 @@ internal class DefaultTimeline( } private fun canPaginate(direction: Timeline.Direction): Boolean { - return isReady.get() && !getPaginationState(direction).isPaginating && hasMoreToLoad(direction) + return isReady.get() && !getState(direction).isPaginating && hasMoreToLoad(direction) } - private fun getPaginationState(direction: Timeline.Direction): PaginationState { + private fun getState(direction: Timeline.Direction): State { return when (direction) { - Timeline.Direction.FORWARDS -> forwardsPaginationState.get() - Timeline.Direction.BACKWARDS -> backwardsPaginationState.get() + Timeline.Direction.FORWARDS -> forwardsState.get() + Timeline.Direction.BACKWARDS -> backwardsState.get() } } - private fun updatePaginationState(direction: Timeline.Direction, update: (PaginationState) -> PaginationState) { + private fun updateState(direction: Timeline.Direction, update: (State) -> State) { val stateReference = when (direction) { - Timeline.Direction.FORWARDS -> forwardsPaginationState - Timeline.Direction.BACKWARDS -> backwardsPaginationState + Timeline.Direction.FORWARDS -> forwardsState + Timeline.Direction.BACKWARDS -> backwardsState } val currentValue = stateReference.get() val newValue = update(currentValue) @@ -477,13 +441,51 @@ internal class DefaultTimeline( postSnapshot() } + /** + * This has to be called on TimelineThread as it access realm live results + */ + private fun handleUpdates(changeSet: OrderedCollectionChangeSet) { + // If changeSet has deletion we are having a gap, so we clear everything + if (changeSet.deletionRanges.isNotEmpty()) { + clearAllValues() + } + var postSnapshot = false + changeSet.insertionRanges.forEach { range -> + val (startDisplayIndex, direction) = if (range.startIndex == 0) { + Pair(filteredEvents[range.length - 1]!!.root!!.displayIndex, Timeline.Direction.FORWARDS) + } else { + Pair(filteredEvents[range.startIndex]!!.root!!.displayIndex, Timeline.Direction.BACKWARDS) + } + val state = getState(direction) + if (state.isPaginating) { + // We are getting new items from pagination + postSnapshot = paginateInternal(startDisplayIndex, direction, state.requestedPaginationCount) + } else { + // We are getting new items from sync + buildTimelineEvents(startDisplayIndex, direction, range.length.toLong()) + postSnapshot = true + } + } + changeSet.changes.forEach { index -> + val eventEntity = filteredEvents[index] + eventEntity?.eventId?.let { eventId -> + postSnapshot = rebuildEvent(eventId) { + buildTimelineEvent(eventEntity) + } || postSnapshot + } + } + if (postSnapshot) { + postSnapshot() + } + } + /** * This has to be called on TimelineThread as it access realm live results */ private fun executePaginationTask(direction: Timeline.Direction, limit: Int) { val token = getTokenLive(direction) if (token == null) { - updatePaginationState(direction) { it.copy(isPaginating = false, requestedCount = 0) } + updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } return } val params = PaginationTask.Params(roomId = roomId, @@ -622,15 +624,6 @@ internal class DefaultTimeline( } } - private fun findCurrentChunk(realm: Realm): ChunkEntity? { - val currentInitialEventId = initialEventId - return if (currentInitialEventId == null) { - ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) - } else { - ChunkEntity.findIncludingEvent(realm, currentInitialEventId) - } - } - private fun clearUnlinkedEvents(realm: Realm) { realm.executeTransaction { val unlinkedChunks = ChunkEntity @@ -651,6 +644,7 @@ internal class DefaultTimeline( if (isReady.get().not()) { return@post } + updateLoadingStates(filteredEvents) val snapshot = createSnapshot() val runnable = Runnable { listener?.onUpdated(snapshot) } debouncer.debounce("post_snapshot", runnable, 50) @@ -662,8 +656,8 @@ internal class DefaultTimeline( nextDisplayIndex = null builtEvents.clear() builtEventsIdMap.clear() - backwardsPaginationState.set(PaginationState()) - forwardsPaginationState.set(PaginationState()) + backwardsState.set(State()) + forwardsState.set(State()) } @@ -673,16 +667,6 @@ internal class DefaultTimeline( return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS } - private fun RealmQuery.findFirst(direction: Timeline.Direction): TimelineEventEntity? { - return if (direction == Timeline.Direction.FORWARDS) { - sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.DESCENDING) - } else { - sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.ASCENDING) - } - .filterEventsWithSettings() - .findFirst() - } - private fun RealmQuery.filterEventsWithSettings(): RealmQuery { if (settings.filterTypes) { `in`(TimelineEventEntityFields.ROOT.TYPE, settings.allowedTypes.toTypedArray()) @@ -693,9 +677,13 @@ internal class DefaultTimeline( return this } + private data class State( + val hasReachedEnd: Boolean = false, + val hasMoreInCache: Boolean = true, + val isPaginating: Boolean = false, + val requestedPaginationCount: Int = 0 + ) + } -private data class PaginationState( - val isPaginating: Boolean = false, - val requestedCount: Int = 0 -) + diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 72f0005d4c..a55bd8bcc0 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -532,7 +532,7 @@ class RoomDetailFragment : private fun updateJumpToBottomViewVisibility() { debouncer.debounce("jump_to_bottom_visibility", 250, Runnable { Timber.v("First visible: ${layoutManager.findFirstCompletelyVisibleItemPosition()}") - if (layoutManager.findFirstCompletelyVisibleItemPosition() != 0) { + if (layoutManager.findFirstVisibleItemPosition() != 0) { jumpToBottomView.show() } else { jumpToBottomView.hide() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnNewMessageCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnNewMessageCallback.kt index 998428477b..f4cfe9eb5a 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnNewMessageCallback.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnNewMessageCallback.kt @@ -26,7 +26,7 @@ class ScrollOnNewMessageCallback(private val layoutManager: LinearLayoutManager, override fun onInserted(position: Int, count: Int) { Timber.v("On inserted $count count at position: $position") - if (position == 0 && layoutManager.findFirstCompletelyVisibleItemPosition() == 0 && !timelineEventController.isLoadingForward()) { + if (position == 0 && layoutManager.findFirstVisibleItemPosition() == 0 && !timelineEventController.isLoadingForward()) { layoutManager.scrollToPosition(0) } } From c6d01fbcf48e3828f7d7e668333190dfdcce2b4e Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 24 Sep 2019 12:57:32 +0200 Subject: [PATCH 15/22] ReadMarker: extract from ViewModel the jump to read marker visibility logic as it's easier to deal with. --- .../home/room/detail/ReadMarkerHelper.kt | 75 ++++++++++++++++ .../home/room/detail/RoomDetailFragment.kt | 67 ++++++++------ .../home/room/detail/RoomDetailViewModel.kt | 88 +++++-------------- .../home/room/detail/RoomDetailViewState.kt | 1 - .../ScrollOnHighlightedEventCallback.kt | 1 - .../timeline/TimelineEventController.kt | 6 +- 6 files changed, 140 insertions(+), 98 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt new file mode 100644 index 0000000000..c162098cff --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt @@ -0,0 +1,75 @@ +/* + + * 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 + +import androidx.recyclerview.widget.LinearLayoutManager +import im.vector.riotx.core.di.ScreenScope +import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import timber.log.Timber +import javax.inject.Inject + +@ScreenScope +class ReadMarkerHelper @Inject constructor() { + + lateinit var timelineEventController: TimelineEventController + lateinit var layoutManager: LinearLayoutManager + var callback: Callback? = null + + private var state: RoomDetailViewState? = null + + fun updateState(state: RoomDetailViewState) { + this.state = state + checkJumpToReadMarkerVisibility() + } + + fun onTimelineScrolled() { + checkJumpToReadMarkerVisibility() + } + + private fun checkJumpToReadMarkerVisibility() { + val nonNullState = this.state ?: return + val lastVisibleItem = layoutManager.findLastVisibleItemPosition() + val readMarkerId = nonNullState.asyncRoomSummary()?.readMarkerId + if (readMarkerId == null) { + callback?.onVisibilityUpdated(false, null) + } + val positionOfReadMarker = timelineEventController.searchPositionOfEvent(readMarkerId) + Timber.v("Position of readMarker: $positionOfReadMarker") + Timber.v("Position of lastVisibleItem: $lastVisibleItem") + if (positionOfReadMarker == null) { + if (nonNullState.timeline?.isLive == true && lastVisibleItem > 0) { + callback?.onVisibilityUpdated(true, readMarkerId) + } else { + callback?.onVisibilityUpdated(false, readMarkerId) + } + } else { + if (positionOfReadMarker > lastVisibleItem) { + callback?.onVisibilityUpdated(true, readMarkerId) + } else { + callback?.onVisibilityUpdated(false, readMarkerId) + } + } + } + + + interface Callback { + fun onVisibilityUpdated(show: Boolean, readMarkerId: String?) + } + + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index a55bd8bcc0..a94e1941a1 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -227,6 +227,7 @@ class RoomDetailFragment : @Inject lateinit var errorFormatter: ErrorFormatter @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer @Inject lateinit var vectorPreferences: VectorPreferences + @Inject lateinit var readMarkerHelper: ReadMarkerHelper private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback @@ -398,22 +399,22 @@ class RoomDetailFragment : if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) { val parser = Parser.builder().build() val document = parser.parse(messageContent.formattedBody - ?: messageContent.body) + ?: messageContent.body) formattedBody = eventHtmlRenderer.render(document) } composerLayout.composerRelatedMessageContent.text = formattedBody - ?: nonFormattedBody + ?: nonFormattedBody updateComposerText(defaultContent) composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) avatarRenderer.render(event.senderAvatar, event.root.senderId - ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) + ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) avatarRenderer.render(event.senderAvatar, - event.root.senderId ?: "", - event.senderName, - composerLayout.composerRelatedMessageAvatar) + event.root.senderId ?: "", + event.senderName, + composerLayout.composerRelatedMessageAvatar) composerLayout.expand { //need to do it here also when not using quick reply focusComposerAndShowKeyboard() @@ -451,9 +452,9 @@ class RoomDetailFragment : REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data) REACTION_SELECT_REQUEST_CODE -> { val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID) - ?: return + ?: return val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT) - ?: return + ?: return //TODO check if already reacted with that? roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId)) } @@ -479,6 +480,13 @@ class RoomDetailFragment : it.dispatchTo(scrollOnNewMessageCallback) it.dispatchTo(scrollOnHighlightedEventCallback) } + readMarkerHelper.timelineEventController = timelineEventController + readMarkerHelper.layoutManager = layoutManager + readMarkerHelper.callback = object : ReadMarkerHelper.Callback { + override fun onVisibilityUpdated(show: Boolean, readMarkerId: String?) { + jumpToReadMarkerView.render(show, readMarkerId) + } + } recyclerView.setController(timelineEventController) recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { @@ -486,6 +494,7 @@ class RoomDetailFragment : if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) { updateJumpToBottomViewVisibility() } + readMarkerHelper.onTimelineScrolled() } override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { @@ -504,26 +513,26 @@ class RoomDetailFragment : if (vectorPreferences.swipeToReplyIsEnabled()) { val swipeCallback = RoomMessageTouchHelperCallback(requireContext(), - R.drawable.ic_reply, - object : RoomMessageTouchHelperCallback.QuickReplayHandler { - override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { - (model as? AbsMessageItem)?.attributes?.informationData?.let { - val eventId = it.eventId - roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString())) - } - } + R.drawable.ic_reply, + object : RoomMessageTouchHelperCallback.QuickReplayHandler { + override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { + (model as? AbsMessageItem)?.attributes?.informationData?.let { + val eventId = it.eventId + roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString())) + } + } - override fun canSwipeModel(model: EpoxyModel<*>): Boolean { - return when (model) { - is MessageFileItem, - is MessageImageVideoItem, - is MessageTextItem -> { - return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED - } - else -> false - } - } - }) + override fun canSwipeModel(model: EpoxyModel<*>): Boolean { + return when (model) { + is MessageFileItem, + is MessageImageVideoItem, + is MessageTextItem -> { + return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED + } + else -> false + } + } + }) val touchHelper = ItemTouchHelper(swipeCallback) touchHelper.attachToRecyclerView(recyclerView) } @@ -698,6 +707,7 @@ class RoomDetailFragment : } private fun renderState(state: RoomDetailViewState) { + readMarkerHelper.updateState(state) renderRoomSummary(state) val summary = state.asyncRoomSummary() val inviter = state.asyncInviter() @@ -726,7 +736,6 @@ class RoomDetailFragment : composerLayout.visibility = View.GONE notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent)) } - jumpToReadMarkerView.render(state.showJumpToReadMarker, summary?.readMarkerId) } private fun renderRoomSummary(state: RoomDetailViewState) { @@ -1151,7 +1160,7 @@ class RoomDetailFragment : roomDetailViewModel.process(RoomDetailActions.RejectInvite) } -// JumpToReadMarkerView.Callback + // JumpToReadMarkerView.Callback override fun onJumpToReadMarkerClicked(readMarkerId: String) { roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(readMarkerId, false)) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index 72a09fbad6..22850a96f0 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -119,7 +119,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro observeRoomSummary() observeEventDisplayedActions() observeSummaryState() - observeJumpToReadMarkerViewVisibility() observeReadMarkerVisibility() observeDrafts() room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear() @@ -185,23 +184,23 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro copy( // Create a sendMode from a draft and retrieve the TimelineEvent sendMode = when (draft) { - is UserDraft.REGULAR -> SendMode.REGULAR(draft.text) - is UserDraft.QUOTE -> { - room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> - SendMode.QUOTE(timelineEvent, draft.text) - } - } - is UserDraft.REPLY -> { - room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> - SendMode.REPLY(timelineEvent, draft.text) - } - } - is UserDraft.EDIT -> { - room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> - SendMode.EDIT(timelineEvent, draft.text) - } - } - } ?: SendMode.REGULAR("") + is UserDraft.REGULAR -> SendMode.REGULAR(draft.text) + is UserDraft.QUOTE -> { + room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> + SendMode.QUOTE(timelineEvent, draft.text) + } + } + is UserDraft.REPLY -> { + room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> + SendMode.REPLY(timelineEvent, draft.text) + } + } + is UserDraft.EDIT -> { + room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> + SendMode.EDIT(timelineEvent, draft.text) + } + } + } ?: SendMode.REGULAR("") ) } } @@ -210,7 +209,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) { val tombstoneContent = action.event.getClearContent().toModel() - ?: return + ?: return val roomId = tombstoneContent.replacementRoom ?: "" val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN @@ -345,7 +344,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro is SendMode.EDIT -> { //is original event a reply? val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel()?.relatesTo?.inReplyTo?.eventId - ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId + ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId if (inReplyTo != null) { //TODO check if same content? room.getTimeLineEvent(inReplyTo)?.let { @@ -354,13 +353,13 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } else { val messageContent: MessageContent? = state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() - ?: state.sendMode.timelineEvent.root.getClearContent().toModel() + ?: state.sendMode.timelineEvent.root.getClearContent().toModel() val existingBody = messageContent?.body ?: "" if (existingBody != action.text) { room.editTextMessage(state.sendMode.timelineEvent.root.eventId ?: "", - messageContent?.type ?: MessageType.MSGTYPE_TEXT, - action.text, - action.autoMarkdown) + messageContent?.type ?: MessageType.MSGTYPE_TEXT, + action.text, + action.autoMarkdown) } else { Timber.w("Same message content, do not send edition") } @@ -371,7 +370,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro is SendMode.QUOTE -> { val messageContent: MessageContent? = state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() - ?: state.sendMode.timelineEvent.root.getClearContent().toModel() + ?: state.sendMode.timelineEvent.root.getClearContent().toModel() val textMsg = messageContent?.body val finalText = legacyRiotQuoteText(textMsg, action.text) @@ -702,45 +701,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro .disposeOnClear() } - private fun observeJumpToReadMarkerViewVisibility() { - Observable.combineLatest( - room.rx().liveRoomSummary() - .map { - val readMarkerId = it.readMarkerId - if (readMarkerId == null) { - Option.empty() - } else { - val readMarkerIndex = room.getTimeLineEvent(readMarkerId)?.displayIndex - ?: Int.MIN_VALUE - Option.just(readMarkerIndex) - } - } - .distinctUntilChanged(), - visibleEventsObservable.distinctUntilChanged(), - isEventVisibleObservable { it.hasReadMarker }.startWith(false).takeUntil { it }, - Function3, RoomDetailActions.TimelineEventTurnsVisible, Boolean, Boolean> { readMarkerIndex, currentVisibleEvent, isReadMarkerViewVisible -> - if (readMarkerIndex.isEmpty() || isReadMarkerViewVisible) { - false - } else { - val currentVisibleEventPosition = currentVisibleEvent.event.displayIndex - readMarkerIndex.getOrElse { Int.MIN_VALUE } < currentVisibleEventPosition - } - } - ) - .distinctUntilChanged() - .subscribe { - setState { copy(showJumpToReadMarker = it) } - } - .disposeOnClear() - } - - private fun isEventVisibleObservable(filterEvent: (TimelineEvent) -> Boolean): Observable { - return Observable.merge( - visibleEventsObservable.filter { filterEvent(it.event) }.map { true }, - invisibleEventsObservable.filter { filterEvent(it.event) }.map { false } - ) - } - private fun observeRoomSummary() { room.rx().liveRoomSummary() .execute { async -> diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt index 3a8afb1ebe..2be78506a0 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt @@ -52,7 +52,6 @@ data class RoomDetailViewState( val tombstoneEvent: Event? = null, val tombstoneEventHandling: Async = Uninitialized, val syncState: SyncState = SyncState.IDLE, - val showJumpToReadMarker: Boolean = false, val highlightedEventId: String? = null, val hideReadMarker: Boolean = false ) : MvRxState { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt index 92e38c112b..08add3f0c7 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt @@ -56,7 +56,6 @@ class ScrollOnHighlightedEventCallback(private val layoutManager: LinearLayoutMa // Do not scroll it item is already visible if (positionToScroll !in firstVisibleItem..lastVisibleItem) { Timber.v("Scroll to $positionToScroll") - // Note: Offset will be from the bottom, since the layoutManager is reversed layoutManager.scrollToPosition(positionToScroll) } scheduledEventId.set(null) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index 701e412b1d..fdc37a7f35 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -158,7 +158,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec synchronized(modelCache) { for (i in 0 until modelCache.size) { if (modelCache[i]?.eventId == viewState.highlightedEventId - || modelCache[i]?.eventId == eventIdToHighlight) { + || modelCache[i]?.eventId == eventIdToHighlight) { modelCache[i] = null } } @@ -229,8 +229,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec // 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]?.mergedHeaderModel != null + || modelCache[position]?.formattedDayModel != null) { modelCache[position] = buildItemModels(position, currentSnapshot) } } From 63b43de4b8c5fb9f3829c8c9340c6fa965c13ae9 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 24 Sep 2019 22:52:43 +0200 Subject: [PATCH 16/22] Read marker: final refact [WIP] --- .../riotx/core/ui/views/ReadMarkerView.kt | 4 +- .../home/room/detail/ReadMarkerHelper.kt | 48 ++++++++++++++---- .../home/room/detail/RoomDetailFragment.kt | 50 ++++++------------- .../home/room/detail/RoomDetailViewModel.kt | 45 ++++++++--------- .../home/room/detail/RoomDetailViewState.kt | 2 +- .../timeline/TimelineEventController.kt | 27 +++++++--- .../timeline/TimelineLayoutManagerHolder.kt | 29 +++++++++++ .../timeline/factory/DefaultItemFactory.kt | 4 +- .../timeline/factory/EncryptedItemFactory.kt | 4 +- .../factory/MergedHeaderItemFactory.kt | 11 ++-- .../timeline/factory/MessageItemFactory.kt | 6 +-- .../timeline/factory/NoticeItemFactory.kt | 4 +- .../timeline/factory/TimelineItemFactory.kt | 14 +++--- .../helper/MessageInformationDataFactory.kt | 6 +-- .../detail/timeline/item/AbsMessageItem.kt | 4 +- .../detail/timeline/item/MergedHeaderItem.kt | 4 +- .../room/detail/timeline/item/NoticeItem.kt | 5 +- 17 files changed, 152 insertions(+), 115 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineLayoutManagerHolder.kt diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt index 55665ca27f..19dad458a5 100644 --- a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt @@ -37,7 +37,7 @@ class ReadMarkerView @JvmOverloads constructor( ) : View(context, attrs, defStyleAttr) { interface Callback { - fun onReadMarkerLongBound() + fun onReadMarkerLongBound(isDisplayed: Boolean) } private var eventId: String? = null @@ -57,7 +57,7 @@ class ReadMarkerView @JvmOverloads constructor( if (hasReadMarker) { callbackDispatcherJob = GlobalScope.launch(Dispatchers.Main) { delay(DELAY_IN_MS) - callback?.onReadMarkerLongBound() + callback?.onReadMarkerLongBound(displayReadMarker) } } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt index c162098cff..85ad6201d3 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt @@ -30,10 +30,25 @@ class ReadMarkerHelper @Inject constructor() { lateinit var layoutManager: LinearLayoutManager var callback: Callback? = null + private var onReadMarkerLongDisplayed = false + private var readMarkerVisible: Boolean = true private var state: RoomDetailViewState? = null - fun updateState(state: RoomDetailViewState) { - this.state = state + fun readMarkerVisible(): Boolean { + return readMarkerVisible + } + + fun onResume() { + onReadMarkerLongDisplayed = false + } + + fun onReadMarkerLongDisplayed() { + onReadMarkerLongDisplayed = true + } + + fun updateWith(newState: RoomDetailViewState) { + state = newState + checkReadMarkerVisibility() checkJumpToReadMarkerVisibility() } @@ -41,34 +56,47 @@ class ReadMarkerHelper @Inject constructor() { checkJumpToReadMarkerVisibility() } + private fun checkReadMarkerVisibility() { + val nonNullState = this.state ?: return + val firstVisibleItem = layoutManager.findFirstVisibleItemPosition() + val lastVisibleItem = layoutManager.findLastVisibleItemPosition() + readMarkerVisible = if (!onReadMarkerLongDisplayed) { + true + } else { + if (nonNullState.timeline?.isLive == false) { + true + } else { + !(firstVisibleItem == 0 && lastVisibleItem > 0) + } + } + } + private fun checkJumpToReadMarkerVisibility() { val nonNullState = this.state ?: return val lastVisibleItem = layoutManager.findLastVisibleItemPosition() val readMarkerId = nonNullState.asyncRoomSummary()?.readMarkerId if (readMarkerId == null) { - callback?.onVisibilityUpdated(false, null) + callback?.onJumpToReadMarkerVisibilityUpdate(false, null) } val positionOfReadMarker = timelineEventController.searchPositionOfEvent(readMarkerId) - Timber.v("Position of readMarker: $positionOfReadMarker") - Timber.v("Position of lastVisibleItem: $lastVisibleItem") if (positionOfReadMarker == null) { if (nonNullState.timeline?.isLive == true && lastVisibleItem > 0) { - callback?.onVisibilityUpdated(true, readMarkerId) + callback?.onJumpToReadMarkerVisibilityUpdate(true, readMarkerId) } else { - callback?.onVisibilityUpdated(false, readMarkerId) + callback?.onJumpToReadMarkerVisibilityUpdate(false, readMarkerId) } } else { if (positionOfReadMarker > lastVisibleItem) { - callback?.onVisibilityUpdated(true, readMarkerId) + callback?.onJumpToReadMarkerVisibilityUpdate(true, readMarkerId) } else { - callback?.onVisibilityUpdated(false, readMarkerId) + callback?.onJumpToReadMarkerVisibilityUpdate(false, readMarkerId) } } } interface Callback { - fun onVisibilityUpdated(show: Boolean, readMarkerId: String?) + fun onJumpToReadMarkerVisibilityUpdate(show: Boolean, readMarkerId: String?) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index a94e1941a1..aadfbb9fcb 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -28,12 +28,7 @@ import android.os.Parcelable import android.text.Editable import android.text.Spannable import android.text.TextUtils -import android.view.HapticFeedbackConstants -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.view.Window +import android.view.* import android.view.inputmethod.InputMethodManager import android.widget.TextView import android.widget.Toast @@ -51,13 +46,7 @@ import androidx.recyclerview.widget.RecyclerView import butterknife.BindView import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.EpoxyVisibilityTracker -import com.airbnb.mvrx.Async -import com.airbnb.mvrx.Fail -import com.airbnb.mvrx.Loading -import com.airbnb.mvrx.Success -import com.airbnb.mvrx.args -import com.airbnb.mvrx.fragmentViewModel -import com.airbnb.mvrx.withState +import com.airbnb.mvrx.* import com.github.piasy.biv.BigImageViewer import com.github.piasy.biv.loader.ImageLoader import com.google.android.material.snackbar.Snackbar @@ -71,13 +60,7 @@ import im.vector.matrix.android.api.permalinks.PermalinkFactory import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.room.model.Membership -import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent -import im.vector.matrix.android.api.session.room.model.message.MessageContent -import im.vector.matrix.android.api.session.room.model.message.MessageFileContent -import im.vector.matrix.android.api.session.room.model.message.MessageImageContent -import im.vector.matrix.android.api.session.room.model.message.MessageTextContent -import im.vector.matrix.android.api.session.room.model.message.MessageType -import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent +import im.vector.matrix.android.api.session.room.model.message.* import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent @@ -124,17 +107,8 @@ import im.vector.riotx.features.home.room.detail.composer.TextComposerViewModel import im.vector.riotx.features.home.room.detail.composer.TextComposerViewState import im.vector.riotx.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController -import im.vector.riotx.features.home.room.detail.timeline.action.ActionsHandler -import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsBottomSheet -import im.vector.riotx.features.home.room.detail.timeline.action.SimpleAction -import im.vector.riotx.features.home.room.detail.timeline.action.ViewEditHistoryBottomSheet -import im.vector.riotx.features.home.room.detail.timeline.action.ViewReactionBottomSheet -import im.vector.riotx.features.home.room.detail.timeline.item.AbsMessageItem -import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem -import im.vector.riotx.features.home.room.detail.timeline.item.MessageImageVideoItem -import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData -import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem -import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData +import im.vector.riotx.features.home.room.detail.timeline.action.* +import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.html.PillImageSpan import im.vector.riotx.features.invite.VectorInviteView @@ -432,8 +406,8 @@ class RoomDetailFragment : } override fun onResume() { + readMarkerHelper.onResume() super.onResume() - notificationDrawerManager.setCurrentRoom(roomDetailArgs.roomId) } @@ -483,7 +457,7 @@ class RoomDetailFragment : readMarkerHelper.timelineEventController = timelineEventController readMarkerHelper.layoutManager = layoutManager readMarkerHelper.callback = object : ReadMarkerHelper.Callback { - override fun onVisibilityUpdated(show: Boolean, readMarkerId: String?) { + override fun onJumpToReadMarkerVisibilityUpdate(show: Boolean, readMarkerId: String?) { jumpToReadMarkerView.render(show, readMarkerId) } } @@ -707,13 +681,13 @@ class RoomDetailFragment : } private fun renderState(state: RoomDetailViewState) { - readMarkerHelper.updateState(state) + readMarkerHelper.updateWith(state) renderRoomSummary(state) val summary = state.asyncRoomSummary() val inviter = state.asyncInviter() if (summary?.membership == Membership.JOIN) { scrollOnHighlightedEventCallback.timeline = state.timeline - timelineEventController.update(state) + timelineEventController.update(state, readMarkerHelper.readMarkerVisible()) inviteView.visibility = View.GONE val uid = session.myUserId val meMember = session.getRoom(state.roomId)?.getRoomMember(uid) @@ -974,7 +948,10 @@ class RoomDetailFragment : .show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS") } - override fun onReadMarkerLongDisplayed() = withState(roomDetailViewModel) { state -> + override fun onReadMarkerLongBound(isDisplayed: Boolean) { + if (isDisplayed) { + readMarkerHelper.onReadMarkerLongDisplayed() + } val firstVisibleItem = layoutManager.findFirstVisibleItemPosition() val nextReadMarkerId = timelineEventController.searchEventIdAtPosition(firstVisibleItem) if (nextReadMarkerId != null) { @@ -982,6 +959,7 @@ class RoomDetailFragment : } } + // AutocompleteUserPresenter.Callback override fun onQueryUsers(query: CharSequence?) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index 22850a96f0..72c6d67a7d 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -21,8 +21,6 @@ import android.text.TextUtils import androidx.annotation.IdRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import arrow.core.Option -import arrow.core.getOrElse import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.Success @@ -43,13 +41,11 @@ import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.getFileUrl -import im.vector.matrix.android.api.session.room.model.relation.ReactionContent import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent import im.vector.matrix.android.api.session.room.send.UserDraft -import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineSettings -import im.vector.matrix.android.api.util.Optional import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent +import im.vector.matrix.android.api.util.Optional import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.matrix.rx.rx @@ -62,11 +58,11 @@ import im.vector.riotx.core.utils.LiveEvent import im.vector.riotx.core.utils.subscribeLogError import im.vector.riotx.features.command.CommandParser import im.vector.riotx.features.command.ParsedCommand +import im.vector.riotx.features.home.room.detail.timeline.TimelineLayoutManagerHolder import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDisplayableEvents import im.vector.riotx.features.settings.VectorPreferences import io.reactivex.Observable import io.reactivex.functions.BiFunction -import io.reactivex.functions.Function3 import io.reactivex.rxkotlin.subscribeBy import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer @@ -76,6 +72,7 @@ import java.util.concurrent.TimeUnit class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: RoomDetailViewState, + private val timelineLayoutManagerHolder: TimelineLayoutManagerHolder, private val userPreferencesProvider: UserPreferencesProvider, private val vectorPreferences: VectorPreferences, private val session: Session @@ -119,8 +116,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro observeRoomSummary() observeEventDisplayedActions() observeSummaryState() - observeReadMarkerVisibility() observeDrafts() + observeReadMarkerVisibility() + observeOwnState() room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear() timeline.start() setState { copy(timeline = this@RoomDetailViewModel.timeline) } @@ -711,23 +709,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } } - private fun observeReadMarkerVisibility() { - Observable - .combineLatest( - room.rx().liveReadMarker(), - room.rx().liveReadReceipt(), - BiFunction, Optional, Boolean> { readMarker, readReceipt -> - readMarker.getOrNull() == readReceipt.getOrNull() - } - ) - .startWith(false) - .subscribe { - setState { copy(hideReadMarker = it) } - } - .disposeOnClear() - } - - private fun observeSummaryState() { asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary -> if (summary.membership == Membership.INVITE) { @@ -743,6 +724,22 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } } + private fun observeReadMarkerVisibility() { + Observable + .combineLatest( + room.rx().liveReadMarker(), + room.rx().liveReadReceipt(), + BiFunction, Optional, Boolean> { readMarker, readReceipt -> + readMarker.getOrNull() != readReceipt.getOrNull() + } + ) + .subscribe { + setState { copy(readMarkerVisible = it) } + } + .disposeOnClear() + } + + override fun onCleared() { timeline.dispose() super.onCleared() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt index 2be78506a0..2609aed2e3 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt @@ -53,7 +53,7 @@ data class RoomDetailViewState( val tombstoneEventHandling: Async = Uninitialized, val syncState: SyncState = SyncState.IDLE, val highlightedEventId: String? = null, - val hideReadMarker: Boolean = false + val readMarkerVisible: Boolean = false ) : MvRxState { constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index fdc37a7f35..525fb6cd6a 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -34,7 +34,10 @@ import im.vector.riotx.core.extensions.localDateTime import im.vector.riotx.features.home.room.detail.RoomDetailViewState import im.vector.riotx.features.home.room.detail.timeline.factory.MergedHeaderItemFactory 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.helper.TimelineEventDiffUtilCallback +import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener +import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider +import im.vector.riotx.features.home.room.detail.timeline.helper.nextOrNull import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.VideoContentRenderer @@ -79,7 +82,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec interface ReadReceiptsCallback { fun onReadReceiptsClicked(readReceipts: List) - fun onReadMarkerLongDisplayed() + fun onReadMarkerLongBound(isDisplayed: Boolean) } interface UrlClickCallback { @@ -141,7 +144,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec requestModelBuild() } - fun update(viewState: RoomDetailViewState) { + fun update(viewState: RoomDetailViewState, readMarkerVisible: Boolean) { if (timeline != viewState.timeline) { timeline = viewState.timeline timeline?.listener = this @@ -166,8 +169,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec eventIdToHighlight = viewState.highlightedEventId requestModelBuild = true } - if (hideReadMarker != viewState.hideReadMarker) { - hideReadMarker = viewState.hideReadMarker + if (this.readMarkerVisible != readMarkerVisible) { + this.readMarkerVisible = readMarkerVisible requestModelBuild = true } if (requestModelBuild) { @@ -175,7 +178,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } - private var hideReadMarker: Boolean = false + private var readMarkerVisible: Boolean = false private var eventIdToHighlight: String? = null override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { @@ -255,11 +258,19 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec val date = event.root.localDateTime() val nextDate = nextEvent?.root?.localDateTime() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() - val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, hideReadMarker, callback).also { + val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, readMarkerVisible, callback).also { it.id(event.localId) it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) } - val mergedHeaderModel = mergedHeaderItemFactory.create(event, nextEvent, items, addDaySeparator, currentPosition, eventIdToHighlight, callback) { + val mergedHeaderModel = mergedHeaderItemFactory.create(event, + nextEvent = nextEvent, + items = items, + addDaySeparator = addDaySeparator, + readMarkerVisible = readMarkerVisible, + currentPosition = currentPosition, + eventIdToHighlight = eventIdToHighlight, + callback = callback + ) { requestModelBuild() } val daySeparatorItem = buildDaySeparatorItem(addDaySeparator, date) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineLayoutManagerHolder.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineLayoutManagerHolder.kt new file mode 100644 index 0000000000..429515798a --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineLayoutManagerHolder.kt @@ -0,0 +1,29 @@ +/* + + * 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 androidx.recyclerview.widget.LinearLayoutManager +import im.vector.riotx.core.di.ScreenScope +import javax.inject.Inject + +@ScreenScope +class TimelineLayoutManagerHolder @Inject constructor() { + + lateinit var layoutManager: LinearLayoutManager + +} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt index a387f3f496..959079bf8b 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt @@ -31,7 +31,7 @@ class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: Ava fun create(event: TimelineEvent, highlight: Boolean, - hideReadMarker: Boolean, + readMarkerVisible: Boolean, callback: TimelineEventController.Callback?, exception: Exception? = null): DefaultItem? { val text = if (exception == null) { @@ -40,7 +40,7 @@ class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: Ava "an exception occurred when rendering the event ${event.root.eventId}" } - val informationData = informationDataFactory.create(event, null, hideReadMarker) + val informationData = informationDataFactory.create(event, null, readMarkerVisible) return DefaultItem_() .leftGuideline(avatarSizeProvider.leftGuideline) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt index 663762850a..e67507d7bb 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt @@ -41,7 +41,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat fun create(event: TimelineEvent, nextEvent: TimelineEvent?, highlight: Boolean, - hideReadMarker: Boolean, + readMarkerVisible: Boolean, callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? { event.root.eventId ?: return null @@ -65,7 +65,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat // TODO This is not correct format for error, change it - val informationData = messageInformationDataFactory.create(event, nextEvent, hideReadMarker) + val informationData = messageInformationDataFactory.create(event, nextEvent, readMarkerVisible) val attributes = attributesFactory.create(null, informationData, callback) return MessageTextItem_() .leftGuideline(avatarSizeProvider.leftGuideline) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index 80b3aa261b..ddf93410a1 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -18,15 +18,9 @@ package im.vector.riotx.features.home.room.detail.timeline.factory import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.riotx.core.di.ActiveSessionHolder -import im.vector.riotx.core.extensions.displayReadMarker import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController -import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider -import im.vector.riotx.features.home.room.detail.timeline.helper.MergedTimelineEventVisibilityStateChangedListener -import im.vector.riotx.features.home.room.detail.timeline.helper.canBeMerged -import im.vector.riotx.features.home.room.detail.timeline.helper.prevSameTypeEvents -import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar -import im.vector.riotx.features.home.room.detail.timeline.helper.senderName +import im.vector.riotx.features.home.room.detail.timeline.helper.* import im.vector.riotx.features.home.room.detail.timeline.item.MergedHeaderItem import im.vector.riotx.features.home.room.detail.timeline.item.MergedHeaderItem_ import javax.inject.Inject @@ -42,6 +36,7 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act nextEvent: TimelineEvent?, items: List, addDaySeparator: Boolean, + readMarkerVisible: Boolean, currentPosition: Int, eventIdToHighlight: String?, callback: TimelineEventController.Callback?, @@ -67,7 +62,7 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act if (readMarkerId == null && mergedEvent.hasReadMarker) { readMarkerId = mergedEvent.root.eventId } - if (!showReadMarker && mergedEvent.displayReadMarker(sessionHolder.getActiveSession().myUserId)) { + if (!showReadMarker && mergedEvent.hasReadMarker && readMarkerVisible) { showReadMarker = true } val senderAvatar = mergedEvent.senderAvatar() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 2cf5a60c44..747ae483c9 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -76,12 +76,12 @@ class MessageItemFactory @Inject constructor( fun create(event: TimelineEvent, nextEvent: TimelineEvent?, highlight: Boolean, - hideReadMarker: Boolean, + readMarkerVisible: Boolean, callback: TimelineEventController.Callback? ): VectorEpoxyModel<*>? { event.root.eventId ?: return null - val informationData = messageInformationDataFactory.create(event, nextEvent, hideReadMarker) + val informationData = messageInformationDataFactory.create(event, nextEvent, readMarkerVisible) if (event.root.isRedacted()) { //message is redacted @@ -98,7 +98,7 @@ class MessageItemFactory @Inject constructor( || event.isEncrypted() && event.root.content.toModel()?.relatesTo?.type == RelationType.REPLACE ) { // This is an edit event, we should it when debugging as a notice event - return noticeItemFactory.create(event, highlight, hideReadMarker, callback) + return noticeItemFactory.create(event, highlight, readMarkerVisible, callback) } val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt index f251a70905..ff7af61fbe 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/NoticeItemFactory.kt @@ -34,11 +34,11 @@ class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEv fun create(event: TimelineEvent, highlight: Boolean, - hideReadMarker: Boolean, + readMarkerVisible: Boolean, callback: TimelineEventController.Callback?): NoticeItem? { val formattedText = eventFormatter.format(event) ?: return null - val informationData = informationDataFactory.create(event, null, hideReadMarker) + val informationData = informationDataFactory.create(event, null, readMarkerVisible) val attributes = NoticeItem.Attributes( avatarRenderer = avatarRenderer, informationData = informationData, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 18254120af..eda00fff95 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -33,14 +33,14 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me fun create(event: TimelineEvent, nextEvent: TimelineEvent?, eventIdToHighlight: String?, - hideReadMarker: Boolean, + readMarkerVisible: Boolean, callback: TimelineEventController.Callback?): VectorEpoxyModel<*> { val highlight = event.root.eventId == eventIdToHighlight val computedModel = try { when (event.root.getClearType()) { - EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, hideReadMarker, callback) + EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, readMarkerVisible, callback) // State and call EventType.STATE_ROOM_TOMBSTONE, EventType.STATE_ROOM_NAME, @@ -52,22 +52,22 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.CALL_ANSWER, EventType.REACTION, EventType.REDACTION, - EventType.ENCRYPTION -> noticeItemFactory.create(event, highlight, hideReadMarker, callback) + EventType.ENCRYPTION -> noticeItemFactory.create(event, highlight, readMarkerVisible, callback) // State room create EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback) // Crypto EventType.ENCRYPTED -> { if (event.root.isRedacted()) { // Redacted event, let the MessageItemFactory handle it - messageItemFactory.create(event, nextEvent, highlight, hideReadMarker, callback) + messageItemFactory.create(event, nextEvent, highlight, readMarkerVisible, callback) } else { - encryptedItemFactory.create(event, nextEvent, highlight, hideReadMarker, callback) + encryptedItemFactory.create(event, nextEvent, highlight, readMarkerVisible, callback) } } // Unhandled event types (yet) EventType.STATE_ROOM_THIRD_PARTY_INVITE, - EventType.STICKER -> defaultItemFactory.create(event, highlight, hideReadMarker, callback) + EventType.STICKER -> defaultItemFactory.create(event, highlight, readMarkerVisible, callback) else -> { Timber.v("Type ${event.root.getClearType()} not handled") null @@ -75,7 +75,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me } } catch (e: Exception) { Timber.e(e, "failed to create message item") - defaultItemFactory.create(event, highlight, hideReadMarker, callback, e) + defaultItemFactory.create(event, highlight, readMarkerVisible, callback, e) } return (computedModel ?: EmptyItem_()) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index 453f7e4cd9..8448ddc059 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -24,10 +24,8 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.hasBeenEdited import im.vector.riotx.core.extensions.localDateTime import im.vector.riotx.core.resources.ColorProvider -import im.vector.riotx.core.utils.isSingleEmoji import im.vector.riotx.features.home.getColorFromUserId import im.vector.riotx.core.date.VectorDateFormatter -import im.vector.riotx.core.extensions.displayReadMarker import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData import im.vector.riotx.features.home.room.detail.timeline.item.ReactionInfoData import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData @@ -41,7 +39,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses private val dateFormatter: VectorDateFormatter, private val colorProvider: ColorProvider) { - fun create(event: TimelineEvent, nextEvent: TimelineEvent?, hideReadMarker: Boolean): MessageInformationData { + fun create(event: TimelineEvent, nextEvent: TimelineEvent?, readMarkerVisible: Boolean): MessageInformationData { // Non nullability has been tested before val eventId = event.root.eventId!! @@ -65,7 +63,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId ?: "")) } - val displayReadMarker = !hideReadMarker && event.displayReadMarker(session.myUserId) + val displayReadMarker = readMarkerVisible && event.hasReadMarker return MessageInformationData( eventId = eventId, diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt index 408a997efd..5bf8ba6e06 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -57,8 +57,8 @@ abstract class AbsMessageItem : BaseEventItem() { private val _readMarkerCallback = object : ReadMarkerView.Callback { - override fun onReadMarkerLongBound() { - attributes.readReceiptsCallback?.onReadMarkerLongDisplayed() + override fun onReadMarkerLongBound(isDisplayed: Boolean) { + attributes.readReceiptsCallback?.onReadMarkerLongBound(isDisplayed) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt index de105b2261..da19a88133 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt @@ -41,8 +41,8 @@ abstract class MergedHeaderItem : BaseEventItem() { private val _readMarkerCallback = object : ReadMarkerView.Callback { - override fun onReadMarkerLongBound() { - attributes.readReceiptsCallback?.onReadMarkerLongDisplayed() + override fun onReadMarkerLongBound(isDisplayed: Boolean) { + attributes.readReceiptsCallback?.onReadMarkerLongBound(isDisplayed) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt index 89270ce026..559b02aa61 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt @@ -38,8 +38,9 @@ abstract class NoticeItem : BaseEventItem() { }) private val _readMarkerCallback = object : ReadMarkerView.Callback { - override fun onReadMarkerLongBound() { - attributes.readReceiptsCallback?.onReadMarkerLongDisplayed() + + override fun onReadMarkerLongBound(isDisplayed: Boolean) { + attributes.readReceiptsCallback?.onReadMarkerLongBound(isDisplayed) } } From 4a80df082ceb85b14964509b5d4f8173ffe14570 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 25 Sep 2019 19:14:12 +0200 Subject: [PATCH 17/22] Timeline: refact [WIP] --- .../EventAnnotationsSummaryEntityQuery.kt | 8 +- .../query/ReadReceiptsSummaryEntityQueries.kt | 9 +- .../query/TimelineEventEntityQueries.kt | 8 +- .../room/EventRelationsAggregationTask.kt | 10 +-- .../session/room/read/SetReadMarkersTask.kt | 21 +++-- .../session/room/send/DefaultSendService.kt | 2 +- .../session/room/timeline/DefaultTimeline.kt | 9 +- .../room/timeline/DefaultTimelineService.kt | 6 +- .../room/timeline/TimelineHiddenReadMarker.kt | 89 ++++++++++++------- .../session/sync/RoomFullyReadHandler.kt | 15 ++-- .../core/ui/views/JumpToReadMarkerView.kt | 31 +++++-- .../riotx/core/ui/views/ReadMarkerView.kt | 1 - .../home/room/detail/ReadMarkerHelper.kt | 25 +++--- .../home/room/detail/RoomDetailFragment.kt | 73 ++++++++------- .../home/room/detail/RoomDetailViewModel.kt | 78 +++++++--------- .../home/room/detail/RoomDetailViewState.kt | 3 +- .../timeline/TimelineEventController.kt | 57 +++++------- .../timeline/TimelineLayoutManagerHolder.kt | 29 ------ .../factory/MergedHeaderItemFactory.kt | 3 +- .../detail/timeline/item/AbsMessageItem.kt | 7 +- .../detail/timeline/item/BaseEventItem.kt | 3 + .../room/detail/timeline/item/DefaultItem.kt | 4 + .../detail/timeline/item/MergedHeaderItem.kt | 10 ++- .../room/detail/timeline/item/NoticeItem.kt | 7 +- .../main/res/layout/fragment_room_detail.xml | 4 +- 25 files changed, 266 insertions(+), 246 deletions(-) delete mode 100644 vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineLayoutManagerHolder.kt diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventAnnotationsSummaryEntityQuery.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventAnnotationsSummaryEntityQuery.kt index f1179ebeb4..0f454b0af7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventAnnotationsSummaryEntityQuery.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventAnnotationsSummaryEntityQuery.kt @@ -38,10 +38,12 @@ internal fun EventAnnotationsSummaryEntity.Companion.whereInRoom(realm: Realm, r } -internal fun EventAnnotationsSummaryEntity.Companion.create(realm: Realm, eventId: String): EventAnnotationsSummaryEntity { - val obj = realm.createObject(EventAnnotationsSummaryEntity::class.java, eventId) +internal fun EventAnnotationsSummaryEntity.Companion.create(realm: Realm, roomId: String, eventId: String): EventAnnotationsSummaryEntity { + val obj = realm.createObject(EventAnnotationsSummaryEntity::class.java, eventId).apply { + this.roomId = roomId + } //Denormalization - TimelineEventEntity.where(realm, eventId = eventId).findFirst()?.let { + TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId).findFirst()?.let { it.annotations = obj } return obj diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadReceiptsSummaryEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadReceiptsSummaryEntityQueries.kt index 0c3d7d8eb1..1773297727 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadReceiptsSummaryEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/ReadReceiptsSummaryEntityQueries.kt @@ -27,10 +27,7 @@ internal fun ReadReceiptsSummaryEntity.Companion.where(realm: Realm, eventId: St .equalTo(ReadReceiptsSummaryEntityFields.EVENT_ID, eventId) } -internal fun ReadReceiptsSummaryEntity.Companion.whereInRoom(realm: Realm, roomId: String?): RealmQuery { - val query = realm.where() - if (roomId != null) { - query.equalTo(ReadReceiptsSummaryEntityFields.ROOM_ID, roomId) - } - return query +internal fun ReadReceiptsSummaryEntity.Companion.whereInRoom(realm: Realm, roomId: String): RealmQuery { + return realm.where() + .equalTo(ReadReceiptsSummaryEntityFields.ROOM_ID, roomId) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/TimelineEventEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/TimelineEventEntityQueries.kt index 182e58a3b5..8b9beca11e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/TimelineEventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/TimelineEventEntityQueries.kt @@ -22,13 +22,15 @@ import im.vector.matrix.android.internal.database.model.EventEntity.LinkFilterMo import io.realm.* import io.realm.kotlin.where -internal fun TimelineEventEntity.Companion.where(realm: Realm, eventId: String): RealmQuery { +internal fun TimelineEventEntity.Companion.where(realm: Realm, roomId: String, eventId: String): RealmQuery { return realm.where() + .equalTo(TimelineEventEntityFields.ROOM_ID, roomId) .equalTo(TimelineEventEntityFields.EVENT_ID, eventId) } -internal fun TimelineEventEntity.Companion.where(realm: Realm, eventIds: List): RealmQuery { +internal fun TimelineEventEntity.Companion.where(realm: Realm, roomId: String, eventIds: List): RealmQuery { return realm.where() + .equalTo(TimelineEventEntityFields.ROOM_ID, roomId) .`in`(TimelineEventEntityFields.EVENT_ID, eventIds.toTypedArray()) } @@ -121,6 +123,6 @@ internal fun TimelineEventEntity.Companion.findAllInRoomWithSendStates(realm: Re val sendStatesStr = sendStates.map { it.name }.toTypedArray() return realm.where() .equalTo(TimelineEventEntityFields.ROOM_ID, roomId) - .`in`(TimelineEventEntityFields.ROOT.SEND_STATE_STR,sendStatesStr) + .`in`(TimelineEventEntityFields.ROOT.SEND_STATE_STR, sendStatesStr) .findAll() } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt index 786ba168ac..3743eef211 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt @@ -85,7 +85,7 @@ internal class DefaultEventRelationsAggregationTask @Inject constructor( EventAnnotationsSummaryEntity.where(realm, event.eventId ?: "").findFirst()?.let { - TimelineEventEntity.where(realm, eventId = event.eventId + TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findFirst()?.let { tet -> tet.annotations = it } @@ -167,8 +167,7 @@ internal class DefaultEventRelationsAggregationTask @Inject constructor( var existing = EventAnnotationsSummaryEntity.where(realm, targetEventId).findFirst() if (existing == null) { Timber.v("###REPLACE creating new relation summary for $targetEventId") - existing = EventAnnotationsSummaryEntity.create(realm, targetEventId) - existing.roomId = roomId + existing = EventAnnotationsSummaryEntity.create(realm, roomId, targetEventId) } //we have it @@ -233,8 +232,7 @@ internal class DefaultEventRelationsAggregationTask @Inject constructor( val eventId = event.eventId ?: "" val existing = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst() if (existing == null) { - val eventSummary = EventAnnotationsSummaryEntity.create(realm, eventId) - eventSummary.roomId = roomId + val eventSummary = EventAnnotationsSummaryEntity.create(realm, roomId, eventId) val sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java) sum.key = it.key sum.firstTimestamp = event.originServerTs ?: 0 //TODO how to maintain order? @@ -261,7 +259,7 @@ internal class DefaultEventRelationsAggregationTask @Inject constructor( val reactionEventId = event.eventId Timber.v("Reaction $reactionEventId relates to $relatedEventID") val eventSummary = EventAnnotationsSummaryEntity.where(realm, relatedEventID).findFirst() - ?: EventAnnotationsSummaryEntity.create(realm, relatedEventID).apply { this.roomId = roomId } + ?: EventAnnotationsSummaryEntity.create(realm, roomId, relatedEventID).apply { this.roomId = roomId } var sum = eventSummary.reactionsSummary.find { it.key == reaction } val txId = event.unsignedData?.transactionId diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt index 6a875b0563..2748a49930 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt @@ -17,9 +17,7 @@ package im.vector.matrix.android.internal.session.room.read import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ReadMarkerEntity -import im.vector.matrix.android.internal.database.model.ReadReceiptEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.query.* @@ -83,7 +81,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI } if (readReceiptEventId != null - && !isEventRead(monarchy, userId, params.roomId, readReceiptEventId)) { + && !isEventRead(monarchy, userId, params.roomId, readReceiptEventId)) { if (LocalEchoEventFactory.isLocalEchoId(readReceiptEventId)) { Timber.w("Can't set read receipt for local event $readReceiptEventId") } else { @@ -112,7 +110,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI val isLatestReceived = TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId == readReceiptId if (isLatestReceived) { val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst() - ?: return@awaitTransaction + ?: return@awaitTransaction roomSummary.notificationCount = 0 roomSummary.highlightCount = 0 roomSummary.hasUnreadMessages = false @@ -121,14 +119,15 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI } } - private fun isReadMarkerMoreRecent(roomId: String, fullyReadEventId: String): Boolean { + private fun isReadMarkerMoreRecent(roomId: String, newReadMarkerId: String): Boolean { return Realm.getInstance(monarchy.realmConfiguration).use { realm -> - val readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId).findFirst() - val readMarkerEvent = readMarkerEntity?.timelineEvent?.firstOrNull() - val eventToCheck = TimelineEventEntity.where(realm, eventId = fullyReadEventId).findFirst() - val readReceiptIndex = readMarkerEvent?.root?.displayIndex ?: Int.MAX_VALUE - val eventToCheckIndex = eventToCheck?.root?.displayIndex ?: Int.MIN_VALUE - eventToCheckIndex > readReceiptIndex + val currentReadMarkerId = ReadMarkerEntity.where(realm, roomId = roomId).findFirst()?.eventId + ?: return true + val readMarkerEvent = TimelineEventEntity.where(realm, roomId = roomId, eventId = currentReadMarkerId).findFirst() + val newReadMarkerEvent = TimelineEventEntity.where(realm, roomId = roomId, eventId = newReadMarkerId).findFirst() + val currentReadMarkerIndex = readMarkerEvent?.root?.displayIndex ?: Int.MAX_VALUE + val newReadMarkerIndex = newReadMarkerEvent?.root?.displayIndex ?: Int.MIN_VALUE + newReadMarkerIndex > currentReadMarkerIndex } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt index a342d3fe72..8b5b84c297 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt @@ -158,7 +158,7 @@ internal class DefaultSendService @AssistedInject constructor(@Assisted private override fun deleteFailedEcho(localEcho: TimelineEvent) { monarchy.writeAsync { realm -> - TimelineEventEntity.where(realm, eventId = localEcho.root.eventId ?: "").findFirst()?.let { + TimelineEventEntity.where(realm, roomId = roomId, eventId = localEcho.root.eventId ?: "").findFirst()?.let { it.deleteFromRealm() } EventEntity.where(realm, eventId = localEcho.root.eventId ?: "").findFirst()?.let { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index 9147b922cb..d95408bfb1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -24,6 +24,7 @@ import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineSettings import im.vector.matrix.android.api.util.CancelableBag +import im.vector.matrix.android.internal.database.helper.deleteOnCascade import im.vector.matrix.android.internal.database.mapper.TimelineEventMapper import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.model.ChunkEntity @@ -625,12 +626,14 @@ internal class DefaultTimeline( } private fun clearUnlinkedEvents(realm: Realm) { - realm.executeTransaction { + realm.executeTransaction { localRealm -> val unlinkedChunks = ChunkEntity - .where(it, roomId = roomId) + .where(localRealm, roomId = roomId) .equalTo("${ChunkEntityFields.TIMELINE_EVENTS.ROOT}.${EventEntityFields.IS_UNLINKED}", true) .findAll() - unlinkedChunks.deleteAllFromRealm() + unlinkedChunks.forEach { + it.deleteOnCascade() + } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt index 0ded458a20..4c4b08ce4f 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimelineService.kt @@ -60,14 +60,14 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv timelineEventMapper, settings, TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings), - TimelineHiddenReadMarker(roomId) + TimelineHiddenReadMarker(roomId, settings) ) } override fun getTimeLineEvent(eventId: String): TimelineEvent? { return monarchy .fetchCopyMap({ - TimelineEventEntity.where(it, eventId = eventId).findFirst() + TimelineEventEntity.where(it, roomId = roomId, eventId = eventId).findFirst() }, { entity, realm -> timelineEventMapper.map(entity) }) @@ -75,7 +75,7 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv override fun getTimeLineEventLive(eventId: String): LiveData { val liveData = RealmLiveData(monarchy.realmConfiguration) { - TimelineEventEntity.where(it, eventId = eventId) + TimelineEventEntity.where(it, roomId = roomId, eventId = eventId) } return Transformations.map(liveData) { events -> events.firstOrNull()?.let { timelineEventMapper.map(it) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt index 7ae6cbcfe1..eebb98ca19 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt @@ -18,12 +18,16 @@ package im.vector.matrix.android.internal.session.room.timeline +import im.vector.matrix.android.api.session.room.timeline.TimelineSettings import im.vector.matrix.android.internal.database.model.ReadMarkerEntity +import im.vector.matrix.android.internal.database.model.ReadMarkerEntityFields import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields +import im.vector.matrix.android.internal.database.query.FilterContent import im.vector.matrix.android.internal.database.query.where +import io.realm.OrderedRealmCollectionChangeListener import io.realm.Realm -import io.realm.RealmObjectChangeListener +import io.realm.RealmQuery import io.realm.RealmResults /** @@ -31,7 +35,8 @@ import io.realm.RealmResults * When an hidden event has read marker, we want to transfer it on the first older displayed event. * It has to be used in [DefaultTimeline] and we should call the [start] and [dispose] methods to properly handle realm subscription. */ -internal class TimelineHiddenReadMarker constructor(private val roomId: String) { +internal class TimelineHiddenReadMarker constructor(private val roomId: String, + private val settings: TimelineSettings) { interface Delegate { fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean @@ -39,39 +44,42 @@ internal class TimelineHiddenReadMarker constructor(private val roomId: String) } private var previousDisplayedEventId: String? = null - private var readMarkerEntity: ReadMarkerEntity? = null + private var hiddenReadMarker: RealmResults? = null private lateinit var liveEvents: RealmResults private lateinit var delegate: Delegate - private val readMarkerListener = RealmObjectChangeListener { readMarker, _ -> - if (!readMarker.isLoaded || !readMarker.isValid) { - return@RealmObjectChangeListener + private val readMarkerListener = OrderedRealmCollectionChangeListener> { readMarkers, changeSet -> + if (!readMarkers.isLoaded || !readMarkers.isValid) { + return@OrderedRealmCollectionChangeListener } var hasChange = false - previousDisplayedEventId?.also { - hasChange = delegate.rebuildEvent(it, false) - previousDisplayedEventId = null - } - val isEventHidden = liveEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, readMarker.eventId).findFirst() == null - if (isEventHidden) { - val hiddenEvent = readMarker.timelineEvent?.firstOrNull() - ?: return@RealmObjectChangeListener - val displayIndex = hiddenEvent.root?.displayIndex - if (displayIndex != null) { - // Then we are looking for the first displayable event after the hidden one - val firstDisplayedEvent = liveEvents.where() - .lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex) - .findFirst() - - // If we find one, we should rebuild this one with marker - if (firstDisplayedEvent != null) { - previousDisplayedEventId = firstDisplayedEvent.eventId - hasChange = delegate.rebuildEvent(firstDisplayedEvent.eventId, true) - } + if (changeSet.deletions.isNotEmpty()) { + previousDisplayedEventId?.also { + hasChange = delegate.rebuildEvent(it, false) + previousDisplayedEventId = null } } - if (hasChange) delegate.onReadMarkerUpdated() + val readMarker = readMarkers.firstOrNull() ?: return@OrderedRealmCollectionChangeListener + val hiddenEvent = readMarker.timelineEvent?.firstOrNull() + ?: return@OrderedRealmCollectionChangeListener + + val displayIndex = hiddenEvent.root?.displayIndex + if (displayIndex != null) { + // Then we are looking for the first displayable event after the hidden one + val firstDisplayedEvent = liveEvents.where() + .lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex) + .findFirst() + + // If we find one, we should rebuild this one with marker + if (firstDisplayedEvent != null) { + previousDisplayedEventId = firstDisplayedEvent.eventId + hasChange = delegate.rebuildEvent(firstDisplayedEvent.eventId, true) + } + } + if (hasChange) { + delegate.onReadMarkerUpdated() + } } @@ -83,8 +91,10 @@ internal class TimelineHiddenReadMarker constructor(private val roomId: String) this.delegate = delegate // We are looking for read receipts set on hidden events. // We only accept those with a timelineEvent (so coming from pagination/sync). - readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId) - .findFirstAsync() + hiddenReadMarker = ReadMarkerEntity.where(realm, roomId = roomId) + .isNotEmpty(ReadMarkerEntityFields.TIMELINE_EVENT) + .filterReceiptsWithSettings() + .findAllAsync() .also { it.addChangeListener(readMarkerListener) } } @@ -93,7 +103,26 @@ internal class TimelineHiddenReadMarker constructor(private val roomId: String) * Dispose the realm query subscription. Has to be called on an HandlerThread */ fun dispose() { - this.readMarkerEntity?.removeAllChangeListeners() + this.hiddenReadMarker?.removeAllChangeListeners() } + /** + * We are looking for readMarker related to filtered events. So, it's the opposite of [DefaultTimeline.filterEventsWithSettings] method. + */ + private fun RealmQuery.filterReceiptsWithSettings(): RealmQuery { + beginGroup() + if (settings.filterTypes) { + not().`in`("${ReadMarkerEntityFields.TIMELINE_EVENT}.${TimelineEventEntityFields.ROOT.TYPE}", settings.allowedTypes.toTypedArray()) + } + if (settings.filterTypes && settings.filterEdits) { + or() + } + if (settings.filterEdits) { + like("${ReadMarkerEntityFields.TIMELINE_EVENT}.${TimelineEventEntityFields.ROOT.CONTENT}", FilterContent.EDIT_TYPE) + } + endGroup() + return this + } + + } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt index 99fbc5750d..fdbaa2ab1b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt @@ -16,10 +16,12 @@ package im.vector.matrix.android.internal.session.sync +import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.session.room.read.FullyReadContent import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity +import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields import im.vector.matrix.android.internal.database.query.getOrCreate import im.vector.matrix.android.internal.database.query.where import io.realm.Realm @@ -37,15 +39,14 @@ internal class RoomFullyReadHandler @Inject constructor() { RoomSummaryEntity.getOrCreate(realm, roomId).apply { readMarkerId = content.eventId } - val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId) - // Remove the old marker if any - if (readMarkerEntity.eventId.isNotEmpty()) { - val oldReadMarkerEvent = TimelineEventEntity.where(realm, eventId = readMarkerEntity.eventId).findFirst() - oldReadMarkerEvent?.readMarker = null + // Remove the old markers if any + val oldReadMarkerEvents = TimelineEventEntity.where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.BOTH).isNotNull(TimelineEventEntityFields.READ_MARKER.`$`).findAll() + oldReadMarkerEvents.forEach { it.readMarker = null } + val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId).apply { + this.eventId = content.eventId } - readMarkerEntity.eventId = content.eventId // Attach to timelineEvent if known - val timelineEventEntity = TimelineEventEntity.where(realm, eventId = content.eventId).findFirst() + val timelineEventEntity = TimelineEventEntity.where(realm, roomId = roomId, eventId = content.eventId).findFirst() timelineEventEntity?.readMarker = readMarkerEntity } diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt index 398d525217..ed81f82e72 100644 --- a/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt @@ -21,16 +21,21 @@ package im.vector.riotx.core.ui.views import android.content.Context import android.util.AttributeSet import android.view.View +import android.view.ViewGroup import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.core.content.ContextCompat import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import butterknife.ButterKnife +import com.airbnb.epoxy.VisibilityState +import com.google.android.material.internal.ViewUtils.dpToPx import im.vector.riotx.R import im.vector.riotx.features.themes.ThemeUtils import kotlinx.android.synthetic.main.view_jump_to_read_marker.view.* import me.gujun.android.span.span import me.saket.bettermovementmethod.BetterLinkMovementMethod +import timber.log.Timber class JumpToReadMarkerView @JvmOverloads constructor( context: Context, @@ -49,26 +54,34 @@ class JumpToReadMarkerView @JvmOverloads constructor( setupView() } + private var readMarkerId: String? = null + private fun setupView() { - LinearLayout.inflate(context, R.layout.view_jump_to_read_marker, this) + inflate(context, R.layout.view_jump_to_read_marker, this) setBackgroundColor(ContextCompat.getColor(context, R.color.notification_accent_color)) jumpToReadMarkerLabelView.movementMethod = BetterLinkMovementMethod.getInstance() isClickable = true + jumpToReadMarkerLabelView.text = span(resources.getString(R.string.room_jump_to_first_unread)) { + textDecorationLine = "underline" + onClick = { + readMarkerId?.also { + callback?.onJumpToReadMarkerClicked(it) + } + } + } closeJumpToReadMarkerView.setOnClickListener { - visibility = View.GONE + visibility = View.INVISIBLE callback?.onClearReadMarkerClicked() } } fun render(show: Boolean, readMarkerId: String?) { - isVisible = show - if (readMarkerId != null) { - jumpToReadMarkerLabelView.text = span(resources.getString(R.string.room_jump_to_first_unread)) { - textDecorationLine = "underline" - onClick = { callback?.onJumpToReadMarkerClicked(readMarkerId) } - } + this.readMarkerId = readMarkerId + visibility = if(show){ + View.VISIBLE + }else { + View.INVISIBLE } - } diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt index 19dad458a5..9e9a147719 100644 --- a/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt @@ -45,7 +45,6 @@ class ReadMarkerView @JvmOverloads constructor( private var callbackDispatcherJob: Job? = null fun bindView(eventId: String?, hasReadMarker: Boolean, displayReadMarker: Boolean, readMarkerCallback: Callback) { - Timber.v("Bind event $eventId - hasReadMarker: $hasReadMarker - displayReadMarker: $displayReadMarker") this.eventId = eventId this.callback = readMarkerCallback if (displayReadMarker) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt index 85ad6201d3..7364c254a8 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt @@ -19,6 +19,7 @@ package im.vector.riotx.features.home.room.detail import androidx.recyclerview.widget.LinearLayoutManager import im.vector.riotx.core.di.ScreenScope +import im.vector.riotx.core.utils.createBackgroundHandler import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import timber.log.Timber import javax.inject.Inject @@ -31,6 +32,7 @@ class ReadMarkerHelper @Inject constructor() { var callback: Callback? = null private var onReadMarkerLongDisplayed = false + private var jumpToReadMarkerVisible = false private var readMarkerVisible: Boolean = true private var state: RoomDetailViewState? = null @@ -75,23 +77,20 @@ class ReadMarkerHelper @Inject constructor() { val nonNullState = this.state ?: return val lastVisibleItem = layoutManager.findLastVisibleItemPosition() val readMarkerId = nonNullState.asyncRoomSummary()?.readMarkerId - if (readMarkerId == null) { - callback?.onJumpToReadMarkerVisibilityUpdate(false, null) - } - val positionOfReadMarker = timelineEventController.searchPositionOfEvent(readMarkerId) - if (positionOfReadMarker == null) { - if (nonNullState.timeline?.isLive == true && lastVisibleItem > 0) { - callback?.onJumpToReadMarkerVisibilityUpdate(true, readMarkerId) - } else { - callback?.onJumpToReadMarkerVisibilityUpdate(false, readMarkerId) - } + val newJumpToReadMarkerVisible = if (readMarkerId == null) { + false } else { - if (positionOfReadMarker > lastVisibleItem) { - callback?.onJumpToReadMarkerVisibilityUpdate(true, readMarkerId) + val positionOfReadMarker = timelineEventController.searchPositionOfEvent(readMarkerId) + if (positionOfReadMarker == null) { + nonNullState.timeline?.isLive == true && lastVisibleItem > 0 } else { - callback?.onJumpToReadMarkerVisibilityUpdate(false, readMarkerId) + positionOfReadMarker > lastVisibleItem } } + if (newJumpToReadMarkerVisible != jumpToReadMarkerVisible) { + jumpToReadMarkerVisible = newJumpToReadMarkerVisible + callback?.onJumpToReadMarkerVisibilityUpdate(jumpToReadMarkerVisible, readMarkerId) + } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index aadfbb9fcb..f490bf66ab 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -157,7 +157,7 @@ class RoomDetailFragment : } } - /** + /**x * Sanitize the display name. * * @param displayName the display name to sanitize @@ -373,22 +373,22 @@ class RoomDetailFragment : if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) { val parser = Parser.builder().build() val document = parser.parse(messageContent.formattedBody - ?: messageContent.body) + ?: messageContent.body) formattedBody = eventHtmlRenderer.render(document) } composerLayout.composerRelatedMessageContent.text = formattedBody - ?: nonFormattedBody + ?: nonFormattedBody updateComposerText(defaultContent) composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) avatarRenderer.render(event.senderAvatar, event.root.senderId - ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) + ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) avatarRenderer.render(event.senderAvatar, - event.root.senderId ?: "", - event.senderName, - composerLayout.composerRelatedMessageAvatar) + event.root.senderId ?: "", + event.senderName, + composerLayout.composerRelatedMessageAvatar) composerLayout.expand { //need to do it here also when not using quick reply focusComposerAndShowKeyboard() @@ -426,9 +426,9 @@ class RoomDetailFragment : REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data) REACTION_SELECT_REQUEST_CODE -> { val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID) - ?: return + ?: return val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT) - ?: return + ?: return //TODO check if already reacted with that? roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId)) } @@ -487,26 +487,26 @@ class RoomDetailFragment : if (vectorPreferences.swipeToReplyIsEnabled()) { val swipeCallback = RoomMessageTouchHelperCallback(requireContext(), - R.drawable.ic_reply, - object : RoomMessageTouchHelperCallback.QuickReplayHandler { - override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { - (model as? AbsMessageItem)?.attributes?.informationData?.let { - val eventId = it.eventId - roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString())) - } - } + R.drawable.ic_reply, + object : RoomMessageTouchHelperCallback.QuickReplayHandler { + override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { + (model as? AbsMessageItem)?.attributes?.informationData?.let { + val eventId = it.eventId + roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString())) + } + } - override fun canSwipeModel(model: EpoxyModel<*>): Boolean { - return when (model) { - is MessageFileItem, - is MessageImageVideoItem, - is MessageTextItem -> { - return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED - } - else -> false - } - } - }) + override fun canSwipeModel(model: EpoxyModel<*>): Boolean { + return when (model) { + is MessageFileItem, + is MessageImageVideoItem, + is MessageTextItem -> { + return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED + } + else -> false + } + } + }) val touchHelper = ItemTouchHelper(swipeCallback) touchHelper.attachToRecyclerView(recyclerView) } @@ -948,12 +948,19 @@ class RoomDetailFragment : .show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS") } - override fun onReadMarkerLongBound(isDisplayed: Boolean) { - if (isDisplayed) { - readMarkerHelper.onReadMarkerLongDisplayed() + override fun onReadMarkerLongBound(readMarkerId: String, isDisplayed: Boolean) { + readMarkerHelper.onReadMarkerLongDisplayed() + val readMarkerIndex = timelineEventController.searchPositionOfEvent(readMarkerId) ?: return + val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition() + if (readMarkerIndex > lastVisibleItemPosition) { + return + } + val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() + val firstVisibleItem = timelineEventController.adapter.getModelAtPosition(firstVisibleItemPosition) + val nextReadMarkerId = when (firstVisibleItem) { + is BaseEventItem -> firstVisibleItem.getEventId() + else -> null } - val firstVisibleItem = layoutManager.findFirstVisibleItemPosition() - val nextReadMarkerId = timelineEventController.searchEventIdAtPosition(firstVisibleItem) if (nextReadMarkerId != null) { roomDetailViewModel.process(RoomDetailActions.SetReadMarkerAction(nextReadMarkerId)) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index 72c6d67a7d..a863337cdd 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -58,7 +58,6 @@ import im.vector.riotx.core.utils.LiveEvent import im.vector.riotx.core.utils.subscribeLogError import im.vector.riotx.features.command.CommandParser import im.vector.riotx.features.command.ParsedCommand -import im.vector.riotx.features.home.room.detail.timeline.TimelineLayoutManagerHolder import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDisplayableEvents import im.vector.riotx.features.settings.VectorPreferences import io.reactivex.Observable @@ -72,7 +71,6 @@ import java.util.concurrent.TimeUnit class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: RoomDetailViewState, - private val timelineLayoutManagerHolder: TimelineLayoutManagerHolder, private val userPreferencesProvider: UserPreferencesProvider, private val vectorPreferences: VectorPreferences, private val session: Session @@ -117,8 +115,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro observeEventDisplayedActions() observeSummaryState() observeDrafts() - observeReadMarkerVisibility() - observeOwnState() room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear() timeline.start() setState { copy(timeline = this@RoomDetailViewModel.timeline) } @@ -182,23 +178,23 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro copy( // Create a sendMode from a draft and retrieve the TimelineEvent sendMode = when (draft) { - is UserDraft.REGULAR -> SendMode.REGULAR(draft.text) - is UserDraft.QUOTE -> { - room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> - SendMode.QUOTE(timelineEvent, draft.text) - } - } - is UserDraft.REPLY -> { - room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> - SendMode.REPLY(timelineEvent, draft.text) - } - } - is UserDraft.EDIT -> { - room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> - SendMode.EDIT(timelineEvent, draft.text) - } - } - } ?: SendMode.REGULAR("") + is UserDraft.REGULAR -> SendMode.REGULAR(draft.text) + is UserDraft.QUOTE -> { + room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> + SendMode.QUOTE(timelineEvent, draft.text) + } + } + is UserDraft.REPLY -> { + room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> + SendMode.REPLY(timelineEvent, draft.text) + } + } + is UserDraft.EDIT -> { + room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> + SendMode.EDIT(timelineEvent, draft.text) + } + } + } ?: SendMode.REGULAR("") ) } } @@ -207,7 +203,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) { val tombstoneContent = action.event.getClearContent().toModel() - ?: return + ?: return val roomId = tombstoneContent.replacementRoom ?: "" val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN @@ -342,7 +338,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro is SendMode.EDIT -> { //is original event a reply? val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel()?.relatesTo?.inReplyTo?.eventId - ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId + ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId if (inReplyTo != null) { //TODO check if same content? room.getTimeLineEvent(inReplyTo)?.let { @@ -351,13 +347,13 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } else { val messageContent: MessageContent? = state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() - ?: state.sendMode.timelineEvent.root.getClearContent().toModel() + ?: state.sendMode.timelineEvent.root.getClearContent().toModel() val existingBody = messageContent?.body ?: "" if (existingBody != action.text) { room.editTextMessage(state.sendMode.timelineEvent.root.eventId ?: "", - messageContent?.type ?: MessageType.MSGTYPE_TEXT, - action.text, - action.autoMarkdown) + messageContent?.type ?: MessageType.MSGTYPE_TEXT, + action.text, + action.autoMarkdown) } else { Timber.w("Same message content, do not send edition") } @@ -368,7 +364,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro is SendMode.QUOTE -> { val messageContent: MessageContent? = state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() - ?: state.sendMode.timelineEvent.root.getClearContent().toModel() + ?: state.sendMode.timelineEvent.root.getClearContent().toModel() val textMsg = messageContent?.body val finalText = legacyRiotQuoteText(textMsg, action.text) @@ -681,7 +677,15 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } private fun handleSetReadMarkerAction(action: RoomDetailActions.SetReadMarkerAction) = withState { state -> - room.setReadMarker(action.eventId, callback = object : MatrixCallback {}) + var readMarkerId = action.eventId + val indexOfEvent = timeline.getIndexOfEvent(readMarkerId) + // force to set the read marker on the next event + if (indexOfEvent != null) { + timeline.getTimelineEventAtIndex(indexOfEvent - 1)?.root?.eventId?.also { eventIdOfNext -> + readMarkerId = eventIdOfNext + } + } + room.setReadMarker(readMarkerId, callback = object : MatrixCallback {}) } private fun handleMarkAllAsRead() { @@ -724,22 +728,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } } - private fun observeReadMarkerVisibility() { - Observable - .combineLatest( - room.rx().liveReadMarker(), - room.rx().liveReadReceipt(), - BiFunction, Optional, Boolean> { readMarker, readReceipt -> - readMarker.getOrNull() != readReceipt.getOrNull() - } - ) - .subscribe { - setState { copy(readMarkerVisible = it) } - } - .disposeOnClear() - } - - override fun onCleared() { timeline.dispose() super.onCleared() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt index 2609aed2e3..7549ffbb23 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt @@ -52,8 +52,7 @@ data class RoomDetailViewState( val tombstoneEvent: Event? = null, val tombstoneEventHandling: Async = Uninitialized, val syncState: SyncState = SyncState.IDLE, - val highlightedEventId: String? = null, - val readMarkerVisible: Boolean = false + val highlightedEventId: String? = null ) : MvRxState { constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index 525fb6cd6a..3dda4b333c 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -82,7 +82,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec interface ReadReceiptsCallback { fun onReadReceiptsClicked(readReceipts: List) - fun onReadMarkerLongBound(isDisplayed: Boolean) + fun onReadMarkerLongBound(readMarkerId: String, isDisplayed: Boolean) } interface UrlClickCallback { @@ -161,7 +161,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec synchronized(modelCache) { for (i in 0 until modelCache.size) { if (modelCache[i]?.eventId == viewState.highlightedEventId - || modelCache[i]?.eventId == eventIdToHighlight) { + || modelCache[i]?.eventId == eventIdToHighlight) { modelCache[i] = null } } @@ -232,8 +232,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec // 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]?.mergedHeaderModel != null + || modelCache[position]?.formattedDayModel != null) { modelCache[position] = buildItemModels(position, currentSnapshot) } } @@ -258,18 +258,24 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec val date = event.root.localDateTime() val nextDate = nextEvent?.root?.localDateTime() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() - val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, readMarkerVisible, callback).also { + // Don't show read marker if it's on first item + val showReadMarker = if (currentPosition == 0 && event.hasReadMarker) { + false + } else { + readMarkerVisible + } + val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, showReadMarker, callback).also { it.id(event.localId) it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) } val mergedHeaderModel = mergedHeaderItemFactory.create(event, - nextEvent = nextEvent, - items = items, - addDaySeparator = addDaySeparator, - readMarkerVisible = readMarkerVisible, - currentPosition = currentPosition, - eventIdToHighlight = eventIdToHighlight, - callback = callback + nextEvent = nextEvent, + items = items, + addDaySeparator = addDaySeparator, + readMarkerVisible = readMarkerVisible, + currentPosition = currentPosition, + eventIdToHighlight = eventIdToHighlight, + callback = callback ) { requestModelBuild() } @@ -317,40 +323,23 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec realPosition++ } for (i in 0 until modelCache.size) { - val itemCache = modelCache[i] - if (itemCache?.eventId == eventId) { + val itemCache = modelCache[i] ?: continue + if (itemCache.eventId == eventId) { return realPosition } - if (itemCache?.eventModel != null) { + if (itemCache.eventModel != null && !mergedHeaderItemFactory.isCollapsed(itemCache.localId)) { realPosition++ } - if (itemCache?.mergedHeaderModel != null) { + if (itemCache.mergedHeaderModel != null) { realPosition++ } - if (itemCache?.formattedDayModel != null) { + if (itemCache.formattedDayModel != null) { realPosition++ } } return null } - fun searchEventIdAtPosition(position: Int): String? = synchronized(modelCache) { - var offsetValue = 0 - for (i in 0 until position) { - val itemCache = modelCache[i] - if (itemCache?.eventModel == null) { - offsetValue-- - } - if (itemCache?.mergedHeaderModel != null) { - offsetValue++ - } - if (itemCache?.formattedDayModel != null) { - offsetValue++ - } - } - return modelCache.getOrNull(position - offsetValue)?.eventId - } - fun isLoadingForward() = showingForwardLoader private data class CacheItemData( diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineLayoutManagerHolder.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineLayoutManagerHolder.kt deleted file mode 100644 index 429515798a..0000000000 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineLayoutManagerHolder.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - - * 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 androidx.recyclerview.widget.LinearLayoutManager -import im.vector.riotx.core.di.ScreenScope -import javax.inject.Inject - -@ScreenScope -class TimelineLayoutManagerHolder @Inject constructor() { - - lateinit var layoutManager: LinearLayoutManager - -} \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index ddf93410a1..b5e5f50e03 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -71,7 +71,8 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act userId = mergedEvent.root.senderId ?: "", avatarUrl = senderAvatar, memberName = senderName ?: "", - eventId = mergedEvent.localId + localId = mergedEvent.localId, + eventId = mergedEvent.root.eventId ?: "" ) mergedData.add(data) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt index 5bf8ba6e06..c7d75998a2 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -29,6 +29,7 @@ import androidx.core.view.children import androidx.core.view.isGone import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.VisibilityState import im.vector.matrix.android.api.session.room.send.SendState import im.vector.riotx.R import im.vector.riotx.core.resources.ColorProvider @@ -58,7 +59,7 @@ abstract class AbsMessageItem : BaseEventItem() { private val _readMarkerCallback = object : ReadMarkerView.Callback { override fun onReadMarkerLongBound(isDisplayed: Boolean) { - attributes.readReceiptsCallback?.onReadMarkerLongBound(isDisplayed) + attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.informationData.eventId, isDisplayed) } } @@ -157,6 +158,10 @@ abstract class AbsMessageItem : BaseEventItem() { return true } + override fun getEventId(): String? { + return attributes.informationData.eventId + } + protected open fun renderSendState(root: View, textView: TextView?, failureIndicator: ImageView? = null) { root.isClickable = attributes.informationData.sendState.isSent() val state = if (attributes.informationData.hasPendingEdits) SendState.UNSENT else attributes.informationData.sendState diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt index 5b0b64fea7..7727b07cd8 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt @@ -44,10 +44,13 @@ abstract class BaseEventItem : VectorEpoxyModel override fun bind(holder: H) { super.bind(holder) + holder holder.leftGuideline.setGuidelineBegin(leftGuideline) holder.checkableBackground.isChecked = highlighted } + abstract fun getEventId(): String? + abstract class BaseHolder(@IdRes val stubId: Int) : VectorEpoxyHolder() { val leftGuideline by bind(R.id.messageStartGuideline) val checkableBackground by bind(R.id.messageSelectedBackground) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt index a5ffb9a2ae..0e2d87512b 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt @@ -55,6 +55,10 @@ abstract class DefaultItem : BaseEventItem() { holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener) } + override fun getEventId(): String? { + return informationData.eventId + } + override fun getViewType() = STUB_ID class Holder : BaseHolder(STUB_ID) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt index da19a88133..727a585d71 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt @@ -42,7 +42,7 @@ abstract class MergedHeaderItem : BaseEventItem() { private val _readMarkerCallback = object : ReadMarkerView.Callback { override fun onReadMarkerLongBound(isDisplayed: Boolean) { - attributes.readReceiptsCallback?.onReadMarkerLongBound(isDisplayed) + attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.readMarkerId ?: "", isDisplayed) } } @@ -89,8 +89,14 @@ abstract class MergedHeaderItem : BaseEventItem() { super.unbind(holder) } + + override fun getEventId(): String? { + return attributes.mergeData.firstOrNull()?.eventId + } + data class Data( - val eventId: Long, + val localId: Long, + val eventId: String, val userId: String, val memberName: String, val avatarUrl: String? diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt index 559b02aa61..c398970e8e 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt @@ -40,7 +40,7 @@ abstract class NoticeItem : BaseEventItem() { private val _readMarkerCallback = object : ReadMarkerView.Callback { override fun onReadMarkerLongBound(isDisplayed: Boolean) { - attributes.readReceiptsCallback?.onReadMarkerLongBound(isDisplayed) + attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.informationData.eventId, isDisplayed) } } @@ -69,6 +69,11 @@ abstract class NoticeItem : BaseEventItem() { super.unbind(holder) } + + override fun getEventId(): String? { + return attributes.informationData.eventId + } + override fun getViewType() = STUB_ID class Holder : BaseHolder(STUB_ID) { diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml index f95afbd647..a9385f4eeb 100644 --- a/vector/src/main/res/layout/fragment_room_detail.xml +++ b/vector/src/main/res/layout/fragment_room_detail.xml @@ -124,10 +124,10 @@ android:id="@+id/jumpToReadMarkerView" android:layout_width="0dp" android:layout_height="wrap_content" - android:visibility="gone" + android:visibility="invisible" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/syncProgressBarWrap" /> + app:layout_constraintTop_toBottomOf="@id/syncStateView" /> Date: Thu, 26 Sep 2019 11:55:16 +0200 Subject: [PATCH 18/22] Timeline: fix some more issues --- .../session/room/timeline/DefaultTimeline.kt | 12 ++-- .../room/timeline/TimelineHiddenReadMarker.kt | 18 ++++-- .../timeline/TimelineHiddenReadReceipts.kt | 25 +++++--- .../session/sync/RoomFullyReadHandler.kt | 10 ++- .../home/room/detail/ReadMarkerHelper.kt | 4 +- .../home/room/detail/RoomDetailFragment.kt | 5 +- .../ScrollOnHighlightedEventCallback.kt | 6 +- .../timeline/TimelineEventController.kt | 64 +++++++++---------- .../detail/timeline/item/AbsMessageItem.kt | 5 +- .../detail/timeline/item/BaseEventItem.kt | 7 +- .../room/detail/timeline/item/DefaultItem.kt | 2 +- .../detail/timeline/item/MergedHeaderItem.kt | 4 +- .../room/detail/timeline/item/NoticeItem.kt | 4 +- 13 files changed, 93 insertions(+), 73 deletions(-) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index d95408bfb1..52ef816e99 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -198,9 +198,9 @@ internal class DefaultTimeline( .also { it.addChangeListener(relationsListener) } if (settings.buildReadReceipts) { - hiddenReadReceipts.start(realm, filteredEvents, this) + hiddenReadReceipts.start(realm, filteredEvents, nonFilteredEvents, this) } - hiddenReadMarker.start(realm, filteredEvents, this) + hiddenReadMarker.start(realm, filteredEvents, nonFilteredEvents, this) isReady.set(true) } } @@ -490,9 +490,9 @@ internal class DefaultTimeline( return } val params = PaginationTask.Params(roomId = roomId, - from = token, - direction = direction.toPaginationDirection(), - limit = limit) + from = token, + direction = direction.toPaginationDirection(), + limit = limit) Timber.v("Should fetch $limit items $direction") cancelableBag += paginationTask @@ -563,7 +563,7 @@ internal class DefaultTimeline( val timelineEvent = buildTimelineEvent(eventEntity) if (timelineEvent.isEncrypted() - && timelineEvent.root.mxDecryptionResult == null) { + && timelineEvent.root.mxDecryptionResult == null) { timelineEvent.root.eventId?.let { eventDecryptor.requestDecryption(it) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt index eebb98ca19..03d79c2e00 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt @@ -46,7 +46,8 @@ internal class TimelineHiddenReadMarker constructor(private val roomId: String, private var previousDisplayedEventId: String? = null private var hiddenReadMarker: RealmResults? = null - private lateinit var liveEvents: RealmResults + private lateinit var filteredEvents: RealmResults + private lateinit var nonFilteredEvents: RealmResults private lateinit var delegate: Delegate private val readMarkerListener = OrderedRealmCollectionChangeListener> { readMarkers, changeSet -> @@ -62,12 +63,13 @@ internal class TimelineHiddenReadMarker constructor(private val roomId: String, } val readMarker = readMarkers.firstOrNull() ?: return@OrderedRealmCollectionChangeListener val hiddenEvent = readMarker.timelineEvent?.firstOrNull() - ?: return@OrderedRealmCollectionChangeListener + ?: return@OrderedRealmCollectionChangeListener + val isLoaded = nonFilteredEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, hiddenEvent.eventId).findFirst() != null val displayIndex = hiddenEvent.root?.displayIndex - if (displayIndex != null) { + if (isLoaded && displayIndex != null) { // Then we are looking for the first displayable event after the hidden one - val firstDisplayedEvent = liveEvents.where() + val firstDisplayedEvent = filteredEvents.where() .lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex) .findFirst() @@ -86,8 +88,12 @@ internal class TimelineHiddenReadMarker constructor(private val roomId: String, /** * Start the realm query subscription. Has to be called on an HandlerThread */ - fun start(realm: Realm, liveEvents: RealmResults, delegate: Delegate) { - this.liveEvents = liveEvents + fun start(realm: Realm, + filteredEvents: RealmResults, + nonFilteredEvents: RealmResults, + delegate: Delegate) { + this.filteredEvents = filteredEvents + this.nonFilteredEvents = nonFilteredEvents this.delegate = delegate // We are looking for read receipts set on hidden events. // We only accept those with a timelineEvent (so coming from pagination/sync). diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt index f932e6f3c0..0c538f794e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt @@ -49,7 +49,8 @@ internal class TimelineHiddenReadReceipts constructor(private val readReceiptsSu private val correctedReadReceiptsByEvent = HashMap>() private lateinit var hiddenReadReceipts: RealmResults - private lateinit var liveEvents: RealmResults + private lateinit var nonFilteredEvents: RealmResults + private lateinit var filteredEvents: RealmResults private lateinit var delegate: Delegate private val hiddenReadReceiptsListener = OrderedRealmCollectionChangeListener> { collection, changeSet -> @@ -60,7 +61,7 @@ internal class TimelineHiddenReadReceipts constructor(private val readReceiptsSu // Deletion here means we don't have any readReceipts for the given hidden events changeSet.deletions.forEach { val eventId = correctedReadReceiptsEventByIndex.get(it, "") - val timelineEvent = liveEvents.where() + val timelineEvent = filteredEvents.where() .equalTo(TimelineEventEntityFields.EVENT_ID, eventId) .findFirst() @@ -70,12 +71,14 @@ internal class TimelineHiddenReadReceipts constructor(private val readReceiptsSu } correctedReadReceiptsEventByIndex.clear() correctedReadReceiptsByEvent.clear() - hiddenReadReceipts.forEachIndexed { index, summary -> - val timelineEvent = summary?.timelineEvent?.firstOrNull() - val displayIndex = timelineEvent?.root?.displayIndex - if (displayIndex != null) { + for (index in 0 until hiddenReadReceipts.size) { + val summary = hiddenReadReceipts[index] ?: continue + val timelineEvent = summary.timelineEvent?.firstOrNull() ?: continue + val isLoaded = nonFilteredEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, timelineEvent.eventId).findFirst() != null + val displayIndex = timelineEvent.root?.displayIndex + if (isLoaded && displayIndex != null) { // Then we are looking for the first displayable event after the hidden one - val firstDisplayedEvent = liveEvents.where() + val firstDisplayedEvent = filteredEvents.where() .lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex) .findFirst() @@ -106,8 +109,12 @@ internal class TimelineHiddenReadReceipts constructor(private val readReceiptsSu /** * Start the realm query subscription. Has to be called on an HandlerThread */ - fun start(realm: Realm, liveEvents: RealmResults, delegate: Delegate) { - this.liveEvents = liveEvents + fun start(realm: Realm, + filteredEvents: RealmResults, + nonFilteredEvents: RealmResults, + delegate: Delegate) { + this.filteredEvents = filteredEvents + this.nonFilteredEvents = nonFilteredEvents this.delegate = delegate // We are looking for read receipts set on hidden events. // We only accept those with a timelineEvent (so coming from pagination/sync). diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt index fdbaa2ab1b..c052cf7146 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt @@ -40,14 +40,18 @@ internal class RoomFullyReadHandler @Inject constructor() { readMarkerId = content.eventId } // Remove the old markers if any - val oldReadMarkerEvents = TimelineEventEntity.where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.BOTH).isNotNull(TimelineEventEntityFields.READ_MARKER.`$`).findAll() + val oldReadMarkerEvents = TimelineEventEntity + .where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.BOTH) + .isNotNull(TimelineEventEntityFields.READ_MARKER.`$`) + .findAll() + oldReadMarkerEvents.forEach { it.readMarker = null } val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId).apply { this.eventId = content.eventId } // Attach to timelineEvent if known - val timelineEventEntity = TimelineEventEntity.where(realm, roomId = roomId, eventId = content.eventId).findFirst() - timelineEventEntity?.readMarker = readMarkerEntity + val timelineEventEntities = TimelineEventEntity.where(realm, roomId = roomId, eventId = content.eventId).findAll() + timelineEventEntities.forEach { it.readMarker = readMarkerEntity } } } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt index 7364c254a8..e23a9084a6 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt @@ -80,7 +80,9 @@ class ReadMarkerHelper @Inject constructor() { val newJumpToReadMarkerVisible = if (readMarkerId == null) { false } else { - val positionOfReadMarker = timelineEventController.searchPositionOfEvent(readMarkerId) + val correctedReadMarkerId = nonNullState.timeline?.getFirstDisplayableEventId(readMarkerId) + ?: readMarkerId + val positionOfReadMarker = timelineEventController.searchPositionOfEvent(correctedReadMarkerId) if (positionOfReadMarker == null) { nonNullState.timeline?.isLive == true && lastVisibleItem > 0 } else { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index f490bf66ab..e391c0d13f 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -250,6 +250,7 @@ class RoomDetailFragment : if (scrollPosition == null) { scrollOnHighlightedEventCallback.scheduleScrollTo(it) } else { + recyclerView.stopScroll() layoutManager.scrollToPosition(scrollPosition) } } @@ -445,7 +446,7 @@ class RoomDetailFragment : layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true) val stateRestorer = LayoutManagerStateRestorer(layoutManager).register() scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager, timelineEventController) - scrollOnHighlightedEventCallback = ScrollOnHighlightedEventCallback(layoutManager, timelineEventController) + scrollOnHighlightedEventCallback = ScrollOnHighlightedEventCallback(recyclerView, layoutManager, timelineEventController) recyclerView.layoutManager = layoutManager recyclerView.itemAnimator = null recyclerView.setHasFixedSize(true) @@ -958,7 +959,7 @@ class RoomDetailFragment : val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition() val firstVisibleItem = timelineEventController.adapter.getModelAtPosition(firstVisibleItemPosition) val nextReadMarkerId = when (firstVisibleItem) { - is BaseEventItem -> firstVisibleItem.getEventId() + is BaseEventItem -> firstVisibleItem.getEventIds().firstOrNull() else -> null } if (nextReadMarkerId != null) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt index 08add3f0c7..52ebba817a 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ScrollOnHighlightedEventCallback.kt @@ -17,9 +17,11 @@ package im.vector.riotx.features.home.room.detail import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.riotx.core.platform.DefaultListUpdateCallback import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import kotlinx.android.synthetic.main.fragment_room_detail.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -29,7 +31,8 @@ import java.util.concurrent.atomic.AtomicReference /** * This handles scrolling to an event which wasn't yet loaded when scheduled. */ -class ScrollOnHighlightedEventCallback(private val layoutManager: LinearLayoutManager, +class ScrollOnHighlightedEventCallback(private val recyclerView: RecyclerView, + private val layoutManager: LinearLayoutManager, private val timelineEventController: TimelineEventController) : DefaultListUpdateCallback { private val scheduledEventId = AtomicReference() @@ -56,6 +59,7 @@ class ScrollOnHighlightedEventCallback(private val layoutManager: LinearLayoutMa // Do not scroll it item is already visible if (positionToScroll !in firstVisibleItem..lastVisibleItem) { Timber.v("Scroll to $positionToScroll") + recyclerView.stopScroll() layoutManager.scrollToPosition(positionToScroll) } scheduledEventId.set(null) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index 3dda4b333c..519d1f71c7 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -50,7 +50,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec private val mergedHeaderItemFactory: MergedHeaderItemFactory, @TimelineEventControllerHandler private val backgroundHandler: Handler -) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener { +) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor { interface Callback : BaseCallback, ReactionPillCallback, AvatarCallback, UrlClickCallback, ReadReceiptsCallback { fun onLoadMore(direction: Timeline.Direction) @@ -91,6 +91,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } private var showingForwardLoader = false + // Map eventId to adapter position + private val adapterPositionMapping = HashMap() private val modelCache = arrayListOf() private var currentSnapshot: List = emptyList() private var inSubmitList: Boolean = false @@ -98,6 +100,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec var callback: Callback? = null + private val listUpdateCallback = object : ListUpdateCallback { override fun onChanged(position: Int, count: Int, payload: Any?) { @@ -141,9 +144,22 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } init { + addInterceptor(this) requestModelBuild() } + // Update position when we are building new items + override fun intercept(models: MutableList>) { + adapterPositionMapping.clear() + models.forEachIndexed { index, epoxyModel -> + if (epoxyModel is BaseEventItem) { + epoxyModel.getEventIds().forEach { + adapterPositionMapping[it] = index + } + } + } + } + fun update(viewState: RoomDetailViewState, readMarkerVisible: Boolean) { if (timeline != viewState.timeline) { timeline = viewState.timeline @@ -161,7 +177,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec synchronized(modelCache) { for (i in 0 until modelCache.size) { if (modelCache[i]?.eventId == viewState.highlightedEventId - || modelCache[i]?.eventId == eventIdToHighlight) { + || modelCache[i]?.eventId == eventIdToHighlight) { modelCache[i] = null } } @@ -186,6 +202,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec timelineMediaSizeProvider.recyclerView = recyclerView } + override fun buildModels() { val timestamp = System.currentTimeMillis() showingForwardLoader = LoadingItem_() @@ -232,8 +249,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec // 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]?.mergedHeaderModel != null + || modelCache[position]?.formattedDayModel != null) { modelCache[position] = buildItemModels(position, currentSnapshot) } } @@ -269,13 +286,13 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) } val mergedHeaderModel = mergedHeaderItemFactory.create(event, - nextEvent = nextEvent, - items = items, - addDaySeparator = addDaySeparator, - readMarkerVisible = readMarkerVisible, - currentPosition = currentPosition, - eventIdToHighlight = eventIdToHighlight, - callback = callback + nextEvent = nextEvent, + items = items, + addDaySeparator = addDaySeparator, + readMarkerVisible = readMarkerVisible, + currentPosition = currentPosition, + eventIdToHighlight = eventIdToHighlight, + callback = callback ) { requestModelBuild() } @@ -314,30 +331,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } fun searchPositionOfEvent(eventId: String?): Int? = synchronized(modelCache) { - // Search in the cache - if (eventId == null) { - return null - } - var realPosition = 0 - if (showingForwardLoader) { - realPosition++ - } - for (i in 0 until modelCache.size) { - val itemCache = modelCache[i] ?: continue - if (itemCache.eventId == eventId) { - return realPosition - } - if (itemCache.eventModel != null && !mergedHeaderItemFactory.isCollapsed(itemCache.localId)) { - realPosition++ - } - if (itemCache.mergedHeaderModel != null) { - realPosition++ - } - if (itemCache.formattedDayModel != null) { - realPosition++ - } - } - return null + return adapterPositionMapping[eventId] } fun isLoadingForward() = showingForwardLoader diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt index c7d75998a2..64547bbcf7 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -29,7 +29,6 @@ import androidx.core.view.children import androidx.core.view.isGone import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute -import com.airbnb.epoxy.VisibilityState import im.vector.matrix.android.api.session.room.send.SendState import im.vector.riotx.R import im.vector.riotx.core.resources.ColorProvider @@ -158,8 +157,8 @@ abstract class AbsMessageItem : BaseEventItem() { return true } - override fun getEventId(): String? { - return attributes.informationData.eventId + override fun getEventIds(): List { + return listOf(attributes.informationData.eventId) } protected open fun renderSendState(root: View, textView: TextView?, failureIndicator: ImageView? = null) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt index 7727b07cd8..c6e813e878 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/BaseEventItem.kt @@ -44,12 +44,15 @@ abstract class BaseEventItem : VectorEpoxyModel override fun bind(holder: H) { super.bind(holder) - holder holder.leftGuideline.setGuidelineBegin(leftGuideline) holder.checkableBackground.isChecked = highlighted } - abstract fun getEventId(): String? + /** + * Returns the eventIds associated with the EventItem. + * Will generally get only one, but it handles the merging items. + */ + abstract fun getEventIds(): List abstract class BaseHolder(@IdRes val stubId: Int) : VectorEpoxyHolder() { val leftGuideline by bind(R.id.messageStartGuideline) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt index 0e2d87512b..cd1e39d37f 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt @@ -55,7 +55,7 @@ abstract class DefaultItem : BaseEventItem() { holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener) } - override fun getEventId(): String? { + override fun getEventIds(): List { return informationData.eventId } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt index 727a585d71..a15d9fa333 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MergedHeaderItem.kt @@ -90,8 +90,8 @@ abstract class MergedHeaderItem : BaseEventItem() { } - override fun getEventId(): String? { - return attributes.mergeData.firstOrNull()?.eventId + override fun getEventIds(): List { + return attributes.mergeData.map { it.eventId } } data class Data( diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt index c398970e8e..2906eb58ba 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/NoticeItem.kt @@ -70,8 +70,8 @@ abstract class NoticeItem : BaseEventItem() { } - override fun getEventId(): String? { - return attributes.informationData.eventId + override fun getEventIds(): List { + return listOf(attributes.informationData.eventId) } override fun getViewType() = STUB_ID From 8605095668aceda0e35ba3a6a3c8dde6bd05b9de Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 26 Sep 2019 16:49:41 +0200 Subject: [PATCH 19/22] Fix quality code issues --- .../session/room/timeline/DefaultTimeline.kt | 15 +++++-- .../room/timeline/TimelineHiddenReadMarker.kt | 5 ++- .../timeline/TimelineHiddenReadReceipts.kt | 4 +- .../core/ui/views/JumpToReadMarkerView.kt | 7 +--- .../home/room/detail/RoomDetailFragment.kt | 39 +++++++++---------- .../detail/timeline/item/AbsMessageItem.kt | 15 +++++-- 6 files changed, 52 insertions(+), 33 deletions(-) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index 52ef816e99..45efe052a7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -258,12 +258,21 @@ internal class DefaultTimeline( } // Otherwise, we should check if the event is in the db, but is hidden because of filters return Realm.getInstance(realmConfiguration).use { localRealm -> - val nonFilteredEvents = buildEventQuery(localRealm).sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.DESCENDING).findAll() - val nonFilteredEvent = nonFilteredEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, eventId).findFirst() + val nonFilteredEvents = buildEventQuery(localRealm) + .sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.DESCENDING) + .findAll() + + val nonFilteredEvent = nonFilteredEvents.where() + .equalTo(TimelineEventEntityFields.EVENT_ID, eventId) + .findFirst() + val filteredEvents = nonFilteredEvents.where().filterEventsWithSettings().findAll() val isEventInDb = nonFilteredEvent != null - val isHidden = isEventInDb && filteredEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, eventId).findFirst() == null + val isHidden = isEventInDb && filteredEvents.where() + .equalTo(TimelineEventEntityFields.EVENT_ID, eventId) + .findFirst() == null + if (isHidden) { val displayIndex = nonFilteredEvent?.root?.displayIndex if (displayIndex != null) { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt index 03d79c2e00..58097c0433 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt @@ -65,7 +65,10 @@ internal class TimelineHiddenReadMarker constructor(private val roomId: String, val hiddenEvent = readMarker.timelineEvent?.firstOrNull() ?: return@OrderedRealmCollectionChangeListener - val isLoaded = nonFilteredEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, hiddenEvent.eventId).findFirst() != null + val isLoaded = nonFilteredEvents.where() + .equalTo(TimelineEventEntityFields.EVENT_ID, hiddenEvent.eventId) + .findFirst() != null + val displayIndex = hiddenEvent.root?.displayIndex if (isLoaded && displayIndex != null) { // Then we are looking for the first displayable event after the hidden one diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt index 0c538f794e..39c2535282 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt @@ -74,8 +74,10 @@ internal class TimelineHiddenReadReceipts constructor(private val readReceiptsSu for (index in 0 until hiddenReadReceipts.size) { val summary = hiddenReadReceipts[index] ?: continue val timelineEvent = summary.timelineEvent?.firstOrNull() ?: continue - val isLoaded = nonFilteredEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, timelineEvent.eventId).findFirst() != null + val isLoaded = nonFilteredEvents.where() + .equalTo(TimelineEventEntityFields.EVENT_ID, timelineEvent.eventId).findFirst() != null val displayIndex = timelineEvent.root?.displayIndex + if (isLoaded && displayIndex != null) { // Then we are looking for the first displayable event after the hidden one val firstDisplayedEvent = filteredEvents.where() diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt index ed81f82e72..3cfd6cf4f8 100644 --- a/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt @@ -25,6 +25,7 @@ import android.view.ViewGroup import android.widget.LinearLayout import android.widget.RelativeLayout import androidx.core.content.ContextCompat +import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import butterknife.ButterKnife @@ -77,11 +78,7 @@ class JumpToReadMarkerView @JvmOverloads constructor( fun render(show: Boolean, readMarkerId: String?) { this.readMarkerId = readMarkerId - visibility = if(show){ - View.VISIBLE - }else { - View.INVISIBLE - } + isInvisible = !show } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 40643f1311..423dd7dc5d 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -488,27 +488,26 @@ class RoomDetailFragment : timelineEventController.callback = this if (vectorPreferences.swipeToReplyIsEnabled()) { - val swipeCallback = RoomMessageTouchHelperCallback(requireContext(), - R.drawable.ic_reply, - object : RoomMessageTouchHelperCallback.QuickReplayHandler { - override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { - (model as? AbsMessageItem)?.attributes?.informationData?.let { - val eventId = it.eventId - roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString())) - } - } + val quickReplyHandler = object : RoomMessageTouchHelperCallback.QuickReplayHandler { + override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { + (model as? AbsMessageItem)?.attributes?.informationData?.let { + val eventId = it.eventId + roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString())) + } + } - override fun canSwipeModel(model: EpoxyModel<*>): Boolean { - return when (model) { - is MessageFileItem, - is MessageImageVideoItem, - is MessageTextItem -> { - return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED - } - else -> false - } - } - }) + override fun canSwipeModel(model: EpoxyModel<*>): Boolean { + return when (model) { + is MessageFileItem, + is MessageImageVideoItem, + is MessageTextItem -> { + return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED + } + else -> false + } + } + } + val swipeCallback = RoomMessageTouchHelperCallback(requireContext(), R.drawable.ic_reply, quickReplyHandler) val touchHelper = ItemTouchHelper(swipeCallback) touchHelper.attachToRecyclerView(recyclerView) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt index 51169fe81b..a4bb5c88cd 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -90,7 +90,12 @@ abstract class AbsMessageItem : BaseEventItem() { holder.timeView.visibility = View.VISIBLE holder.timeView.text = attributes.informationData.time holder.memberNameView.text = attributes.informationData.memberName - attributes.avatarRenderer.render(attributes.informationData.avatarUrl, attributes.informationData.senderId, attributes.informationData.memberName?.toString(), holder.avatarImageView) + attributes.avatarRenderer.render( + attributes.informationData.avatarUrl, + attributes.informationData.senderId, + attributes.informationData.memberName?.toString(), + holder.avatarImageView + ) holder.avatarImageView.setOnLongClickListener(attributes.itemLongClickListener) holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener) } else { @@ -104,8 +109,12 @@ abstract class AbsMessageItem : BaseEventItem() { } holder.view.setOnClickListener(attributes.itemClickListener) holder.view.setOnLongClickListener(attributes.itemLongClickListener) - - holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) + + holder.readReceiptsView.render( + attributes.informationData.readReceipts, + attributes.avatarRenderer, + _readReceiptsClickListener + ) holder.readMarkerView.bindView( attributes.informationData.eventId, attributes.informationData.hasReadMarker, From 28315be7b9bc2810ffa712e75dd83b73ce1e42d7 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 26 Sep 2019 17:05:18 +0200 Subject: [PATCH 20/22] Update CHANGES --- CHANGES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index dc4c743bc4..7c37f66b4d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,12 +7,15 @@ Features: Improvements: - Persist active tab between sessions (#503) - Do not upload file too big for the homeserver (#587) + - Handle read markers (#84) Other changes: - Bugfix: - Fix issue on upload error in loop (#587) + - Fix opening a permalink: the targeted event is displayed twice (#556) + - Fix opening a permalink paginates all the history up to the last event (#282) Translations: - From e842bf13b25a84d891b8ab94e12aae7e492a07ae Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 30 Sep 2019 14:56:06 +0200 Subject: [PATCH 21/22] Timeline: fix back pagination state --- .../session/room/timeline/DefaultTimeline.kt | 28 +++++++++++-------- .../room/timeline/TokenChunkEventPersistor.kt | 17 ++++++----- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index 45efe052a7..3afde4efb5 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -18,6 +18,7 @@ package im.vector.matrix.android.internal.session.room.timeline import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.crypto.CryptoService +import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.room.model.ReadReceipt import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.timeline.Timeline @@ -351,7 +352,7 @@ internal class DefaultTimeline( updateState(Timeline.Direction.BACKWARDS) { it.copy( hasMoreInCache = lastBuiltEvent == null || lastBuiltEvent.displayIndex > lastCacheEvent?.root?.displayIndex ?: Int.MAX_VALUE, - hasReachedEnd = chunkEntity?.isLastBackward ?: false + hasReachedEnd = chunkEntity?.isLastBackward ?: false || lastCacheEvent?.root?.type == EventType.STATE_ROOM_CREATE ) } } @@ -499,9 +500,9 @@ internal class DefaultTimeline( return } val params = PaginationTask.Params(roomId = roomId, - from = token, - direction = direction.toPaginationDirection(), - limit = limit) + from = token, + direction = direction.toPaginationDirection(), + limit = limit) Timber.v("Should fetch $limit items $direction") cancelableBag += paginationTask @@ -510,13 +511,18 @@ internal class DefaultTimeline( this.constraints = TaskConstraints(connectedToNetwork = true) this.callback = object : MatrixCallback { override fun onSuccess(data: TokenChunkEventPersistor.Result) { - if (data == TokenChunkEventPersistor.Result.SUCCESS) { - Timber.v("Success fetching $limit items $direction from pagination request") - } else { - // Database won't be updated, so we force pagination request - BACKGROUND_HANDLER.post { - executePaginationTask(direction, limit) + when (data) { + TokenChunkEventPersistor.Result.SUCCESS -> { + Timber.v("Success fetching $limit items $direction from pagination request") } + TokenChunkEventPersistor.Result.REACHED_END -> { + postSnapshot() + } + TokenChunkEventPersistor.Result.SHOULD_FETCH_MORE -> + // Database won't be updated, so we force pagination request + BACKGROUND_HANDLER.post { + executePaginationTask(direction, limit) + } } } @@ -572,7 +578,7 @@ internal class DefaultTimeline( val timelineEvent = buildTimelineEvent(eventEntity) if (timelineEvent.isEncrypted() - && timelineEvent.root.mxDecryptionResult == null) { + && timelineEvent.root.mxDecryptionResult == null) { timelineEvent.root.eventId?.let { eventDecryptor.requestDecryption(it) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt index 0305002959..2703b5fb91 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -100,6 +100,7 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy enum class Result { SHOULD_FETCH_MORE, + REACHED_END, SUCCESS } @@ -124,10 +125,8 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy prevToken = receivedChunk.end } - if (ChunkEntity.find(realm, roomId, nextToken = nextToken) != null || ChunkEntity.find(realm, roomId, prevToken = prevToken) != null) { - Timber.v("Already inserted - SKIP") - return@awaitTransaction - } + val shouldSkip = ChunkEntity.find(realm, roomId, nextToken = nextToken) != null + || ChunkEntity.find(realm, roomId, prevToken = prevToken) != null val prevChunk = ChunkEntity.find(realm, roomId, nextToken = prevToken) val nextChunk = ChunkEntity.find(realm, roomId, prevToken = nextToken) @@ -146,7 +145,7 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy if (receivedChunk.events.isEmpty() && receivedChunk.end == receivedChunk.start) { Timber.v("Reach end of $roomId") currentChunk.isLastBackward = true - } else { + } else if (!shouldSkip) { Timber.v("Add ${receivedChunk.events.size} events in chunk(${currentChunk.nextToken} | ${currentChunk.prevToken}") val eventIds = ArrayList(receivedChunk.events.size) for (event in receivedChunk.events) { @@ -180,8 +179,12 @@ internal class TokenChunkEventPersistor @Inject constructor(private val monarchy currentChunk.updateSenderDataFor(eventIds) } } - return if (receivedChunk.events.isEmpty() && receivedChunk.stateEvents.isEmpty() && receivedChunk.start != receivedChunk.end) { - Result.SHOULD_FETCH_MORE + return if (receivedChunk.events.isEmpty()) { + if (receivedChunk.start != receivedChunk.end) { + Result.SHOULD_FETCH_MORE + } else { + Result.REACHED_END + } } else { Result.SUCCESS } From 31397869b2c5d2a69d15eaf2d4f4ca25ae1c5a62 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 1 Oct 2019 12:33:38 +0200 Subject: [PATCH 22/22] Read marker: refine JumpToReafMarkerView --- .../riotx/core/ui/views/JumpToReadMarkerView.kt | 11 +++-------- .../src/main/res/layout/view_jump_to_read_marker.xml | 5 ++++- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt index 3cfd6cf4f8..c44b10e31f 100644 --- a/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt @@ -60,14 +60,9 @@ class JumpToReadMarkerView @JvmOverloads constructor( private fun setupView() { inflate(context, R.layout.view_jump_to_read_marker, this) setBackgroundColor(ContextCompat.getColor(context, R.color.notification_accent_color)) - jumpToReadMarkerLabelView.movementMethod = BetterLinkMovementMethod.getInstance() - isClickable = true - jumpToReadMarkerLabelView.text = span(resources.getString(R.string.room_jump_to_first_unread)) { - textDecorationLine = "underline" - onClick = { - readMarkerId?.also { - callback?.onJumpToReadMarkerClicked(it) - } + jumpToReadMarkerLabelView.setOnClickListener { + readMarkerId?.also { + callback?.onJumpToReadMarkerClicked(it) } } closeJumpToReadMarkerView.setOnClickListener { diff --git a/vector/src/main/res/layout/view_jump_to_read_marker.xml b/vector/src/main/res/layout/view_jump_to_read_marker.xml index 4ded65e8f8..aac22b3311 100644 --- a/vector/src/main/res/layout/view_jump_to_read_marker.xml +++ b/vector/src/main/res/layout/view_jump_to_read_marker.xml @@ -10,11 +10,13 @@ android:id="@+id/jumpToReadMarkerLabelView" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginStart="16dp" android:layout_toStartOf="@+id/closeJumpToReadMarkerView" android:drawableStart="@drawable/arrow_up_circle" android:drawablePadding="10dp" + android:background="?attr/selectableItemBackground" android:gravity="center_vertical" + android:paddingStart="16dp" + android:paddingEnd="16dp" android:paddingTop="12dp" android:paddingBottom="12dp" android:text="@string/room_jump_to_first_unread" @@ -23,6 +25,7 @@