diff --git a/CHANGES.md b/CHANGES.md index fd141bcdfb..531bc25e25 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) - after login, the icon in the top left is a green 'A' for (all communities) rather than my avatar (#267) Translations: 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-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 28a3d40070..d83f2b738f 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 @@ -22,13 +22,14 @@ 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.send.UserDraft 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> { @@ -40,7 +41,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 92414eb768..1cf81027f4 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 @@ -49,7 +49,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/model/RoomSummary.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/RoomSummary.kt index 099deae937..4aa9e58539 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 @@ -38,9 +38,14 @@ data class RoomSummary( val tags: List = emptyList(), val membership: Membership = Membership.NONE, val versioningState: VersioningState = VersioningState.NONE, + val readMarkerId: String? = null, val userDrafts: List = emptyList() ) { 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/read/ReadService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/read/ReadService.kt index d97fc497f0..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,7 +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 live read marker id for the room. + */ + 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/Timeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt index 314c9f61b8..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 @@ -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,13 @@ 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?) + + /** * Check if the timeline can be enriched by paginating. * @param the direction to check in @@ -49,6 +58,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 +66,37 @@ interface Timeline { */ fun paginate(direction: Direction, count: Int) - fun pendingEventCount() : 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? - fun failedToDeliverEventCount() : Int interface Listener { /** 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 73ef7b779a..7f90bcff10 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 @@ -40,7 +40,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/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/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 4fbe7fe04c..2f34d18770 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 @@ -68,6 +68,7 @@ internal class RoomSummaryMapper @Inject constructor( tags = tags, membership = roomSummaryEntity.membership, versioningState = roomSummaryEntity.versioningState, + readMarkerId = roomSummaryEntity.readMarkerId, userDrafts = roomSummaryEntity.userDrafts?.userDrafts?.map { DraftMapper.map(it) } ?: emptyList() ) } 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..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 @@ -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?.isNotEmpty() == true ) } 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/RoomSummaryEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/RoomSummaryEntity.kt index 1c159b23d2..b42e367024 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,6 +35,7 @@ internal open class RoomSummaryEntity(@PrimaryKey var roomId: String = "", var otherMemberIds: RealmList = RealmList(), var notificationCount: Int = 0, var highlightCount: Int = 0, + var readMarkerId: String? = null, var hasUnreadMessages: Boolean = false, var tags: RealmList = RealmList(), var userDrafts: UserDraftsEntity? = null 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 ffe20d9efe..21b2fdce5a 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 @@ -44,6 +44,7 @@ import io.realm.annotations.RealmModule PusherEntity::class, PusherDataEntity::class, ReadReceiptsSummaryEntity::class, + ReadMarkerEntity::class, UserDraftsEntity::class, DraftEntity::class, HomeServerCapabilitiesEntity::class 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/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/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/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/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/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/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/di/MatrixScope.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixScope.kt index fadcdacf21..a0886c11b9 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 @@ -24,4 +24,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/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/DefaultRoom.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt index 6b2a6843f1..ac1a828a4f 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 @@ -56,7 +56,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/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/DefaultReadService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/DefaultReadService.kt index 67182e1501..c158f09e19 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 @@ -24,8 +24,11 @@ import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.MatrixCallback 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.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.query.isEventRead import im.vector.matrix.android.internal.database.query.where @@ -78,6 +81,24 @@ internal class DefaultReadService @AssistedInject constructor(@Assisted private return isEventRead(monarchy, userId, roomId, eventId) } + override fun getReadMarkerLive(): LiveData> { + val liveRealmData = RealmLiveData(monarchy.realmConfiguration) { realm -> + ReadMarkerEntity.where(realm, roomId) + } + return Transformations.map(liveRealmData) { results -> + Optional.from(results.firstOrNull()?.eventId) + } + } + + override fun getMyReadReceiptLive(): LiveData> { + val liveRealmData = RealmLiveData(monarchy.realmConfiguration) { realm -> + ReadReceiptEntity.where(realm, roomId = roomId, userId = userId) + } + return Transformations.map(liveRealmData) { results -> + Optional.from(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/FullyReadContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/FullyReadContent.kt new file mode 100644 index 0000000000..6790ea658c --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/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.internal.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/internal/session/room/read/SetReadMarkersTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/read/SetReadMarkersTask.kt index ac4712943d..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,19 +17,20 @@ 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.ReadReceiptEntity +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.find -import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom +import im.vector.matrix.android.internal.database.query.* import im.vector.matrix.android.internal.database.query.latestEvent import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.di.UserId 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 import io.realm.Realm import timber.log.Timber import javax.inject.Inject @@ -48,15 +49,18 @@ private const val READ_MARKER = "m.fully_read" private const val READ_RECEIPT = "m.read" internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI: RoomAPI, - @UserId private val userId: String, - private val monarchy: Monarchy -) : SetReadMarkersTask { + private val monarchy: Monarchy, + private val roomFullyReadHandler: RoomFullyReadHandler, + private val readReceiptHandler: ReadReceiptHandler, + @UserId private val userId: String) + : SetReadMarkersTask { override suspend fun execute(params: SetReadMarkersTask.Params) { val markers = HashMap() 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,58 +72,63 @@ 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}") + Timber.w("Can't set read marker for local event $fullyReadEventId") } else { markers[READ_MARKER] = fullyReadEventId } } - if (readReceiptEventId != null - && !isEventRead(params.roomId, readReceiptEventId)) { + if (readReceiptEventId != null + && !isEventRead(monarchy, userId, 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 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 - roomSummary.notificationCount = 0 - roomSummary.highlightCount = 0 - roomSummary.hasUnreadMessages = false + 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 readReceiptContent = ReadReceiptHandler.createContent(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 + roomSummary.notificationCount = 0 + roomSummary.highlightCount = 0 + roomSummary.hasUnreadMessages = false + } } } } - private fun isEventRead(roomId: String, eventId: String): Boolean { - var isEventRead = false - monarchy.doWithRealm { - val readReceipt = ReadReceiptEntity.where(it, roomId, userId).findFirst() - ?: return@doWithRealm - val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(it, roomId) - ?: return@doWithRealm - val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex - ?: Int.MIN_VALUE - val eventToCheckIndex = liveChunk.timelineEvents.find(eventId)?.root?.displayIndex - ?: Int.MAX_VALUE - isEventRead = eventToCheckIndex <= readReceiptIndex + private fun isReadMarkerMoreRecent(roomId: String, newReadMarkerId: String): Boolean { + return Realm.getInstance(monarchy.realmConfiguration).use { realm -> + 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 } - return isEventRead } } \ No newline at end of file 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 03f5da6e6f..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 @@ -25,6 +25,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 @@ -37,8 +38,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 @@ -60,14 +59,15 @@ 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, - private val initialEventId: String? = null, + private var initialEventId: String? = null, private val realmConfiguration: RealmConfiguration, private val taskExecutor: TaskExecutor, private val contextOfEventTask: GetContextOfEventTask, @@ -75,8 +75,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") @@ -97,93 +98,54 @@ 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 - private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN - private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN - private val isLive = initialEventId == null + 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()) - private val forwardsPaginationState = AtomicReference(PaginationState()) + private val backwardsState = AtomicReference(State()) + private val forwardsState = AtomicReference(State()) private val timelineID = UUID.randomUUID().toString() + override val isLive + get() = !hasMoreToLoad(Timeline.Direction.FORWARDS) + 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 { - // 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() - } - changeSet.insertionRanges.forEach { range -> - val (startDisplayIndex, direction) = if (range.startIndex == 0) { - Pair(liveEvents[range.length - 1]!!.root!!.displayIndex, Timeline.Direction.FORWARDS) - } else { - Pair(liveEvents[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 -> - builtEventsIdMap[eventId]?.let { builtIndex -> - //Update an existing event - builtEvents[builtIndex]?.let { te -> - builtEvents[builtIndex] = buildTimelineEvent(eventEntity) - hasChanged = true - } - } - } - } - if (hasChanged) postSnapshot() + handleUpdates(changeSet) } } - private val relationsListener = OrderedRealmCollectionChangeListener> { collection, changeSet -> + 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() + if (hasChange) postSnapshot() } - // Public methods ****************************************************************************** +// Public methods ****************************************************************************** override fun paginate(direction: Timeline.Direction, count: Int) { BACKGROUND_HANDLER.post { @@ -220,16 +182,15 @@ internal class DefaultTimeline( backgroundRealm.set(realm) clearUnlinkedEvents(realm) - roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst()?.also { it.sendingTimelineEvents.addChangeListener { _ -> postSnapshot() } } - 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) } @@ -238,9 +199,9 @@ internal class DefaultTimeline( .also { it.addChangeListener(relationsListener) } if (settings.buildReadReceipts) { - hiddenReadReceipts.start(realm, liveEvents, this) + hiddenReadReceipts.start(realm, filteredEvents, nonFilteredEvents, this) } - + hiddenReadMarker.start(realm, filteredEvents, nonFilteredEvents, this) isReady.set(true) } } @@ -248,20 +209,85 @@ 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() + filteredEvents.removeAllChangeListeners() + hiddenReadMarker.dispose() if (settings.buildReadReceipts) { hiddenReadReceipts.dispose() } + clearAllValues() backgroundRealm.getAndSet(null).also { it.close() } } + eventDecryptor.destroy() + } + } + + override fun restartWithEventId(eventId: String?) { + dispose() + initialEventId = eventId + start() + postSnapshot() + } + + 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 + } } } @@ -269,50 +295,65 @@ internal class DefaultTimeline( return hasMoreInCache(direction) || !hasReachedEnd(direction) } - // TimelineHiddenReadReceipts.Delegate +// 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 ***************************************************************************** +// TimelineHiddenReadMarker.Delegate - 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) { - if (findCurrentChunk(localRealm)?.isLastForward == true) { - return false - } - val firstEvent = builtEvents.firstOrNull() ?: return true - firstEvent.displayIndex < timelineEventEntity.root!!.displayIndex - } else { - val lastEvent = builtEvents.lastOrNull() ?: return true - lastEvent.displayIndex > timelineEventEntity.root!!.displayIndex - } + override fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean { + return rebuildEvent(eventId) { te -> + te.copy(hasReadMarker = hasReadMarker) } } - 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 + override fun onReadMarkerUpdated() { + postSnapshot() + } + +// 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) = getState(direction).hasMoreInCache + + 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 || lastCacheEvent?.root?.type == EventType.STATE_ROOM_CREATE + ) } } @@ -321,19 +362,20 @@ 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 { - updatePaginationState(direction) { it.copy(requestedCount = count, isPaginating = true) } - val builtCount = buildTimelineEvents(startDisplayIndex, direction, count.toLong()) + count: Int, + strict: Boolean = false): Boolean { + 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) } - val fetchingCount = Math.max(MIN_FETCHING_COUNT, 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 @@ -345,7 +387,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() @@ -358,20 +400,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) @@ -383,37 +425,80 @@ internal class DefaultTimeline( */ private fun handleInitialLoad() { var shouldFetchInitialEvent = false - val initialDisplayIndex = if (isLive) { - 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 - } ?: DISPLAY_INDEX_UNKNOWN - + } prevDisplayIndex = initialDisplayIndex nextDisplayIndex = initialDisplayIndex - if (initialEventId != null && shouldFetchInitialEvent) { - fetchEvent(initialEventId) + if (currentInitialEventId != null && shouldFetchInitialEvent) { + fetchEvent(currentInitialEventId) } else { - val count = Math.min(settings.initialSize, liveEvents.size) - if (isLive) { - paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count) + val count = min(settings.initialSize, filteredEvents.size) + if (initialEventId == null) { + 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() } + /** + * 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) ?: return + val token = getTokenLive(direction) + if (token == null) { + updateState(direction) { it.copy(isPaginating = false, requestedPaginationCount = 0) } + return + } val params = PaginationTask.Params(roomId = roomId, from = token, direction = direction.toPaginationDirection(), @@ -426,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) + } } } @@ -458,21 +548,22 @@ 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() } /** * 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 } @@ -513,16 +604,23 @@ internal class DefaultTimeline( */ private fun getOffsetResults(startDisplayIndex: Int, direction: Timeline.Direction, - count: Long): RealmResults { - val offsetQuery = liveEvents.where() + count: Long, + strict: Boolean): RealmResults { + val offsetQuery = filteredEvents.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) @@ -542,51 +640,51 @@ internal class DefaultTimeline( } } - private fun findCurrentChunk(realm: Realm): ChunkEntity? { - return if (initialEventId == null) { - ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) - } else { - ChunkEntity.findIncludingEvent(realm, initialEventId) - } - } - 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() + } } } 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() { - val snapshot = createSnapshot() - val runnable = Runnable { listener?.onUpdated(snapshot) } - debouncer.debounce("post_snapshot", runnable, 50) + BACKGROUND_HANDLER.post { + if (isReady.get().not()) { + return@post + } + updateLoadingStates(filteredEvents) + val snapshot = createSnapshot() + val runnable = Runnable { listener?.onUpdated(snapshot) } + debouncer.debounce("post_snapshot", runnable, 50) + } } + private fun clearAllValues() { + prevDisplayIndex = null + nextDisplayIndex = null + builtEvents.clear() + builtEventsIdMap.clear() + backwardsState.set(State()) + forwardsState.set(State()) + } + + // Extension methods *************************************************************************** private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { 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()) @@ -597,9 +695,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/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..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 @@ -59,22 +59,23 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv cryptoService, timelineEventMapper, settings, - TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings) + TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings), + 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) }) } - override fun liveTimeLineEvent(eventId: String): LiveData { + 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 new file mode 100644 index 0000000000..58097c0433 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadMarker.kt @@ -0,0 +1,137 @@ +/* + + * 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.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.RealmQuery +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, + private val settings: TimelineSettings) { + + interface Delegate { + fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean + fun onReadMarkerUpdated() + } + + private var previousDisplayedEventId: String? = null + private var hiddenReadMarker: RealmResults? = null + + private lateinit var filteredEvents: RealmResults + private lateinit var nonFilteredEvents: RealmResults + private lateinit var delegate: Delegate + + private val readMarkerListener = OrderedRealmCollectionChangeListener> { readMarkers, changeSet -> + if (!readMarkers.isLoaded || !readMarkers.isValid) { + return@OrderedRealmCollectionChangeListener + } + var hasChange = false + if (changeSet.deletions.isNotEmpty()) { + previousDisplayedEventId?.also { + hasChange = delegate.rebuildEvent(it, false) + previousDisplayedEventId = null + } + } + val readMarker = readMarkers.firstOrNull() ?: return@OrderedRealmCollectionChangeListener + val hiddenEvent = readMarker.timelineEvent?.firstOrNull() + ?: return@OrderedRealmCollectionChangeListener + + 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 + val firstDisplayedEvent = filteredEvents.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, + 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). + hiddenReadMarker = ReadMarkerEntity.where(realm, roomId = roomId) + .isNotEmpty(ReadMarkerEntityFields.TIMELINE_EVENT) + .filterReceiptsWithSettings() + .findAllAsync() + .also { it.addChangeListener(readMarkerListener) } + + } + + /** + * Dispose the realm query subscription. Has to be called on an HandlerThread + */ + fun dispose() { + 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/room/timeline/TimelineHiddenReadReceipts.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineHiddenReadReceipts.kt index 5658210302..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 @@ -49,15 +49,19 @@ 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 -> + 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 { - val eventId = correctedReadReceiptsEventByIndex[it] - val timelineEvent = liveEvents.where() + val eventId = correctedReadReceiptsEventByIndex.get(it, "") + val timelineEvent = filteredEvents.where() .equalTo(TimelineEventEntityFields.EVENT_ID, eventId) .findFirst() @@ -67,12 +71,16 @@ 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() @@ -103,8 +111,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/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt index af845040ae..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 } @@ -112,7 +113,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? @@ -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) @@ -141,12 +140,12 @@ 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") 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) { @@ -163,8 +162,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) @@ -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 } @@ -194,7 +197,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/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/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..c052cf7146 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomFullyReadHandler.kt @@ -0,0 +1,57 @@ +/* + * 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.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 +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}") + + RoomSummaryEntity.getOrCreate(realm, roomId).apply { + 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() + + oldReadMarkerEvents.forEach { it.readMarker = null } + val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId).apply { + this.eventId = content.eventId + } + // Attach to timelineEvent if known + 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/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 11ebff7048..91119f6376 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 @@ -24,8 +24,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.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 +import im.vector.matrix.android.internal.database.helper.lastStateIndex +import im.vector.matrix.android.internal.database.helper.updateSenderDataFor import im.vector.matrix.android.internal.crypto.DefaultCryptoService -import im.vector.matrix.android.internal.database.helper.* 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 @@ -38,7 +43,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 @@ -51,6 +60,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 cryptoService: DefaultCryptoService, private val tokenStore: SyncTokenStore, private val pushRuleService: DefaultPushRuleService, @@ -135,7 +145,8 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch // State event if (roomSync.state != null && roomSync.state.events.isNotEmpty()) { - val minStateIndex = roomEntity.untimelinedStateEvents.where().min(EventEntityFields.STATE_INDEX)?.toInt() ?: Int.MIN_VALUE + val minStateIndex = roomEntity.untimelinedStateEvents.where().min(EventEntityFields.STATE_INDEX)?.toInt() + ?: Int.MIN_VALUE val untimelinedStateIndex = minStateIndex + 1 roomSync.state.events.forEach { event -> roomEntity.addStateEvent(event, filterDuplicates = true, stateIndex = untimelinedStateIndex) @@ -244,11 +255,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/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/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/platform/BadgeFloatingActionButton.kt b/vector/src/main/java/im/vector/riotx/core/platform/BadgeFloatingActionButton.kt new file mode 100644 index 0000000000..4de26cd657 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/platform/BadgeFloatingActionButton.kt @@ -0,0 +1,194 @@ +/* + * 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() + } + } + + var drawBadge: Boolean = false + set(value) { + if (field != value) { + field = value + 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 || 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) + } + } + + 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/core/ui/views/JumpToReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt new file mode 100644 index 0000000000..c44b10e31f --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt @@ -0,0 +1,80 @@ +/* + + * 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.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 +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, + 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 var readMarkerId: String? = null + + private fun setupView() { + inflate(context, R.layout.view_jump_to_read_marker, this) + setBackgroundColor(ContextCompat.getColor(context, R.color.notification_accent_color)) + jumpToReadMarkerLabelView.setOnClickListener { + readMarkerId?.also { + callback?.onJumpToReadMarkerClicked(it) + } + } + closeJumpToReadMarkerView.setOnClickListener { + visibility = View.INVISIBLE + callback?.onClearReadMarkerClicked() + } + } + + fun render(show: Boolean, readMarkerId: String?) { + this.readMarkerId = readMarkerId + isInvisible = !show + } + + +} 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..9e9a147719 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/ReadMarkerView.kt @@ -0,0 +1,92 @@ +/* + + * 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 androidx.core.view.isInvisible +import im.vector.riotx.R +import kotlinx.coroutines.* +import timber.log.Timber + +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 onReadMarkerLongBound(isDisplayed: Boolean) + } + + private var eventId: String? = null + private var callback: Callback? = null + private var callbackDispatcherJob: Job? = null + + fun bindView(eventId: String?, hasReadMarker: Boolean, displayReadMarker: Boolean, readMarkerCallback: Callback) { + this.eventId = eventId + this.callback = readMarkerCallback + if (displayReadMarker) { + startAnimation() + } else { + this.animation?.cancel() + this.visibility = INVISIBLE + } + if (hasReadMarker) { + callbackDispatcherJob = GlobalScope.launch(Dispatchers.Main) { + delay(DELAY_IN_MS) + callback?.onReadMarkerLongBound(displayReadMarker) + } + } + } + + fun unbind() { + this.callbackDispatcherJob?.cancel() + this.callback = null + this.eventId = 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) {} + }) + } + 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 new file mode 100644 index 0000000000..5001449c3f --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/core/utils/Debouncer.kt @@ -0,0 +1,50 @@ +/* + + * 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 + } + + fun cancelAll() { + handler.removeCallbacksAndMessages(null) + } + + 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/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/ReadMarkerHelper.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt new file mode 100644 index 0000000000..e23a9084a6 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt @@ -0,0 +1,104 @@ +/* + + * 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.core.utils.createBackgroundHandler +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 onReadMarkerLongDisplayed = false + private var jumpToReadMarkerVisible = false + private var readMarkerVisible: Boolean = true + private var state: RoomDetailViewState? = null + + fun readMarkerVisible(): Boolean { + return readMarkerVisible + } + + fun onResume() { + onReadMarkerLongDisplayed = false + } + + fun onReadMarkerLongDisplayed() { + onReadMarkerLongDisplayed = true + } + + fun updateWith(newState: RoomDetailViewState) { + state = newState + checkReadMarkerVisibility() + checkJumpToReadMarkerVisibility() + } + + fun onTimelineScrolled() { + 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 + val newJumpToReadMarkerVisible = if (readMarkerId == null) { + false + } else { + val correctedReadMarkerId = nonNullState.timeline?.getFirstDisplayableEventId(readMarkerId) + ?: readMarkerId + val positionOfReadMarker = timelineEventController.searchPositionOfEvent(correctedReadMarkerId) + if (positionOfReadMarker == null) { + nonNullState.timeline?.isLive == true && lastVisibleItem > 0 + } else { + positionOfReadMarker > lastVisibleItem + } + } + if (newJumpToReadMarkerVisible != jumpToReadMarkerVisible) { + jumpToReadMarkerVisible = newJumpToReadMarkerVisible + callback?.onJumpToReadMarkerVisibilityUpdate(jumpToReadMarkerVisible, readMarkerId) + } + } + + + interface Callback { + fun onJumpToReadMarkerVisibilityUpdate(show: Boolean, readMarkerId: String?) + } + + +} \ 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 4aeb4f973a..886c3cdfaf 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 @@ -27,13 +27,16 @@ sealed class RoomDetailActions { data class SaveDraft(val draft: String) : 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() object AcceptInvite : RoomDetailActions() @@ -49,5 +52,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 ad9201c628..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 @@ -61,6 +61,7 @@ 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.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.user.model.User @@ -75,8 +76,11 @@ 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.platform.VectorBaseFragment +import im.vector.riotx.core.ui.views.JumpToReadMarkerView import im.vector.riotx.core.ui.views.NotificationAreaView import im.vector.riotx.core.utils.* +import im.vector.riotx.core.utils.Debouncer +import im.vector.riotx.core.utils.createUIHandler import im.vector.riotx.features.autocomplete.command.AutocompleteCommandPresenter import im.vector.riotx.features.autocomplete.command.CommandAutocompletePolicy import im.vector.riotx.features.autocomplete.user.AutocompleteUserPresenter @@ -92,7 +96,6 @@ 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.helper.EndlessRecyclerViewScrollListener import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.html.PillImageSpan @@ -131,7 +134,8 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callback, AutocompleteUserPresenter.Callback, - VectorInviteView.Callback { + VectorInviteView.Callback, + JumpToReadMarkerView.Callback { companion object { @@ -141,7 +145,7 @@ class RoomDetailFragment : } } - /** + /**x * Sanitize the display name. * * @param displayName the display name to sanitize @@ -158,6 +162,7 @@ class RoomDetailFragment : private const val ircPattern = " (IRC)" } + private val roomDetailArgs: RoomDetailArgs by args() private val glideRequests by lazy { GlideApp.with(this) @@ -166,6 +171,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 @@ -177,16 +184,19 @@ 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 + @Inject lateinit var readMarkerHelper: ReadMarkerHelper + + private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback + private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback override fun getLayoutResId() = R.layout.fragment_room_detail 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 @@ -206,6 +216,8 @@ class RoomDetailFragment : setupAttachmentButton() setupInviteView() setupNotificationView() + setupJumpToReadMarkerView() + setupJumpToBottomView() roomDetailViewModel.subscribe { renderState(it) } textComposerViewModel.subscribe { renderTextComposerState(it) } roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) } @@ -219,8 +231,13 @@ class RoomDetailFragment : } roomDetailViewModel.navigateToEvent.observeEvent(this) { - // - scrollOnHighlightedEventCallback.scheduleScrollTo(it) + val scrollPosition = timelineEventController.searchPositionOfEvent(it) + if (scrollPosition == null) { + scrollOnHighlightedEventCallback.scheduleScrollTo(it) + } else { + recyclerView.stopScroll() + layoutManager.scrollToPosition(scrollPosition) + } } roomDetailViewModel.fileTooBigEvent.observeEvent(this) { @@ -254,6 +271,29 @@ class RoomDetailFragment : } } + override fun onDestroy() { + debouncer.cancelAll() + super.onDestroy() + } + + private fun setupJumpToBottomView() { + jumpToBottomView.visibility = View.INVISIBLE + jumpToBottomView.setOnClickListener { + jumpToBottomView.visibility = View.INVISIBLE + withState(roomDetailViewModel) { state -> + if (state.timeline?.isLive == false) { + state.timeline.restartWithEventId(null) + } else { + layoutManager.scrollToPosition(0) + } + } + } + } + + private fun setupJumpToReadMarkerView() { + jumpToReadMarkerView.callback = this + } + private fun displayFileTooBigWarning(error: FileTooBigError) { AlertDialog.Builder(requireActivity()) .setTitle(R.string.dialog_title_error) @@ -335,20 +375,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 + composerLayout.composerRelatedMessageContent.text = formattedBody + ?: nonFormattedBody updateComposerText(defaultContent) composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) + avatarRenderer.render(event.senderAvatar, event.root.senderId + ?: "", 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() @@ -366,8 +408,8 @@ class RoomDetailFragment : } override fun onResume() { + readMarkerHelper.onResume() super.onResume() - notificationDrawerManager.setCurrentRoom(roomDetailArgs.roomId) } @@ -386,9 +428,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)) } @@ -398,13 +440,14 @@ class RoomDetailFragment : // PRIVATE METHODS ***************************************************************************** + 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) + scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager, timelineEventController) + scrollOnHighlightedEventCallback = ScrollOnHighlightedEventCallback(recyclerView, layoutManager, timelineEventController) recyclerView.layoutManager = layoutManager recyclerView.itemAnimator = null recyclerView.setHasFixedSize(true) @@ -413,41 +456,74 @@ class RoomDetailFragment : it.dispatchTo(scrollOnNewMessageCallback) it.dispatchTo(scrollOnHighlightedEventCallback) } - - recyclerView.addOnScrollListener( - EndlessRecyclerViewScrollListener(layoutManager, RoomDetailViewModel.PAGINATION_COUNT) { direction -> - roomDetailViewModel.process(RoomDetailActions.LoadMoreTimelineEvents(direction)) - }) + readMarkerHelper.timelineEventController = timelineEventController + readMarkerHelper.layoutManager = layoutManager + readMarkerHelper.callback = object : ReadMarkerHelper.Callback { + override fun onJumpToReadMarkerVisibilityUpdate(show: Boolean, readMarkerId: String?) { + jumpToReadMarkerView.render(show, readMarkerId) + } + } 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() + } + readMarkerHelper.onTimelineScrolled() + } + + 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)?.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).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) } } + private fun updateJumpToBottomViewVisibility() { + debouncer.debounce("jump_to_bottom_visibility", 250, Runnable { + Timber.v("First visible: ${layoutManager.findFirstCompletelyVisibleItemPosition()}") + if (layoutManager.findFirstVisibleItemPosition() != 0) { + jumpToBottomView.show() + } else { + jumpToBottomView.hide() + } + }) + } + private fun setupComposer() { val elevation = 6f val backgroundDrawable = ColorDrawable(ThemeUtils.getColor(requireContext(), R.attr.riotx_background)) @@ -606,11 +682,13 @@ class RoomDetailFragment : } private fun renderState(state: RoomDetailViewState) { + readMarkerHelper.updateWith(state) renderRoomSummary(state) val summary = state.asyncRoomSummary() val inviter = state.asyncInviter() if (summary?.membership == Membership.JOIN) { - timelineEventController.setTimeline(state.timeline, state.eventId) + scrollOnHighlightedEventCallback.timeline = state.timeline + timelineEventController.update(state, readMarkerHelper.readMarkerVisible()) inviteView.visibility = View.GONE val uid = session.myUserId val meMember = session.getRoom(state.roomId)?.getRoomMember(uid) @@ -637,6 +715,7 @@ class RoomDetailFragment : private fun renderRoomSummary(state: RoomDetailViewState) { state.asyncRoomSummary()?.let { + if (it.membership.isLeft()) { Timber.w("The room has been left") activity?.finish() @@ -645,6 +724,8 @@ class RoomDetailFragment : avatarRenderer.render(it, roomToolbarAvatarImageView) roomToolbarSubtitleView.setTextOrHide(it.topic) } + jumpToBottomView.count = it.notificationCount + jumpToBottomView.drawBadge = it.hasUnreadMessages } } @@ -671,7 +752,6 @@ class RoomDetailFragment : } } - private fun renderSendMessageResult(sendMessageResult: SendMessageResult) { when (sendMessageResult) { is SendMessageResult.MessageSent -> { @@ -721,7 +801,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 } @@ -741,7 +821,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) { @@ -803,6 +887,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) { } @@ -861,7 +949,26 @@ class RoomDetailFragment : .show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS") } -// AutocompleteUserPresenter.Callback + 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.getEventIds().firstOrNull() + else -> null + } + if (nextReadMarkerId != null) { + roomDetailViewModel.process(RoomDetailActions.SetReadMarkerAction(nextReadMarkerId)) + } + } + + + // AutocompleteUserPresenter.Callback override fun onQueryUsers(query: CharSequence?) { textComposerViewModel.process(TextComposerActions.QueryUsers(query)) @@ -1026,7 +1133,8 @@ class RoomDetailFragment : snack.show() } -// VectorInviteView.Callback + + // VectorInviteView.Callback override fun onAcceptInvite() { notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) @@ -1037,4 +1145,15 @@ 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 f1d2431351..a4623a5b23 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 @@ -46,6 +46,7 @@ import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneCo import im.vector.matrix.android.api.session.room.send.UserDraft import im.vector.matrix.android.api.session.room.timeline.TimelineSettings 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 @@ -61,6 +62,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.BiFunction import io.reactivex.rxkotlin.subscribeBy import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer @@ -78,7 +81,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 { @@ -120,32 +124,39 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro fun process(action: RoomDetailActions) { when (action) { - is RoomDetailActions.SaveDraft -> handleSaveDraft(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.ExitSpecialMode -> handleExitSpecialMode(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.SaveDraft -> handleSaveDraft(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.ExitSpecialMode -> handleExitSpecialMode(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) + } + /** * Convert a send mode to a draft and save the draft */ @@ -169,23 +180,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("") ) } } @@ -193,7 +204,8 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) { - val tombstoneContent = action.event.getClearContent().toModel() ?: return + val tombstoneContent = action.event.getClearContent().toModel() + ?: return val roomId = tombstoneContent.replacementRoom ?: "" val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN @@ -327,7 +339,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 { @@ -336,13 +348,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") } @@ -353,7 +365,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) @@ -487,14 +499,14 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } } - 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)) } } } @@ -581,11 +593,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( @@ -614,56 +621,18 @@ 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 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 = correctedEventId) } + } + _navigateToEvent.postLiveEvent(correctedEventId) } private fun handleResendEvent(action: RoomDetailActions.ResendMessage) { @@ -709,7 +678,7 @@ 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 -> @@ -721,6 +690,22 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro .disposeOnClear() } + private fun handleSetReadMarkerAction(action: RoomDetailActions.SetReadMarkerAction) = withState { state -> + 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() { + room.markAllAsRead(object : MatrixCallback {}) + } + private fun observeSyncState() { session.rx() .liveSyncState() 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 a47ee56500..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 @@ -51,7 +51,8 @@ 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 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..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,28 +17,50 @@ 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 +import timber.log.Timber import java.util.concurrent.atomic.AtomicReference -class ScrollOnHighlightedEventCallback(private val layoutManager: LinearLayoutManager, +/** + * This handles scrolling to an event which wasn't yet loaded when scheduled. + */ +class ScrollOnHighlightedEventCallback(private val recyclerView: RecyclerView, + private val layoutManager: LinearLayoutManager, private val timelineEventController: TimelineEventController) : DefaultListUpdateCallback { private val scheduledEventId = AtomicReference() + 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 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() // 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) + 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/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 1a0e8d180b..b51f080fe5 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,17 +24,22 @@ 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 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.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.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.core.utils.DimensionConverter import im.vector.riotx.features.home.AvatarRenderer -import im.vector.riotx.features.home.room.detail.timeline.factory.TimelineItemFactory -import im.vector.riotx.features.home.room.detail.timeline.helper.* import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.VideoContentRenderer @@ -44,14 +49,16 @@ import javax.inject.Inject class TimelineEventController @Inject constructor(private val dateFormatter: VectorDateFormatter, private val timelineItemFactory: TimelineItemFactory, private val timelineMediaSizeProvider: TimelineMediaSizeProvider, + private val mergedHeaderItemFactory: MergedHeaderItemFactory, private val avatarRenderer: AvatarRenderer, private val dimensionConverter: DimensionConverter, @TimelineEventControllerHandler - private val backgroundHandler: Handler, - userPreferencesProvider: UserPreferencesProvider -) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener { + private val backgroundHandler: Handler +) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor { interface Callback : BaseCallback, ReactionPillCallback, AvatarCallback, UrlClickCallback, ReadReceiptsCallback { + fun onLoadMore(direction: Timeline.Direction) + fun onEventInvisible(event: TimelineEvent) fun onEventVisible(event: TimelineEvent) fun onRoomCreateLinkClicked(url: String) fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) @@ -79,6 +86,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec interface ReadReceiptsCallback { fun onReadReceiptsClicked(readReceipts: List) + fun onReadMarkerLongBound(readMarkerId: String, isDisplayed: Boolean) } interface UrlClickCallback { @@ -86,16 +94,17 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec fun onUrlLongClicked(url: String): Boolean } - private val collapsedEventIds = linkedSetOf() - private val mergeItemCollapseStates = HashMap() + 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 private var timeline: Timeline? = null var callback: Callback? = null + private val listUpdateCallback = object : ListUpdateCallback { override fun onChanged(position: Int, count: Int, payload: Any?) { @@ -138,17 +147,27 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } - private val showHiddenEvents = userPreferencesProvider.shouldShowHiddenEvents() - init { + addInterceptor(this) requestModelBuild() } - fun setTimeline(timeline: Timeline?, eventIdToHighlight: String?) { - if (this.timeline != timeline) { - this.timeline = timeline - this.timeline?.listener = this + // 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 + timeline?.listener = this // Clear cache synchronized(modelCache) { for (i in 0 until modelCache.size) { @@ -156,23 +175,30 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } } } - - if (this.eventIdToHighlight != eventIdToHighlight) { + var requestModelBuild = false + 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.readMarkerVisible != readMarkerVisible) { + this.readMarkerVisible = readMarkerVisible + requestModelBuild = true + } + if (requestModelBuild) { requestModelBuild() } } + private var readMarkerVisible: Boolean = false private var eventIdToHighlight: String? = null override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { @@ -180,18 +206,22 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec timelineMediaSizeProvider.recyclerView = recyclerView } + override fun buildModels() { - val loaderAdded = LoadingItem_() - .id("forward_loading_item") + val timestamp = System.currentTimeMillis() + showingForwardLoader = LoadingItem_() + .id("forward_loading_item_$timestamp") + .setVisibilityStateChangedListener(Timeline.Direction.FORWARDS) .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") + .setVisibilityStateChangedListener(Timeline.Direction.BACKWARDS) .addWhen(Timeline.Direction.BACKWARDS) } } @@ -223,14 +253,14 @@ 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) } } 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 @@ -245,16 +275,31 @@ 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() - - val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, 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 = buildMergedHeaderItem(event, nextEvent, items, addDaySeparator, currentPosition) + 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) return CacheItemData(event.localId, event.root.eventId, eventModel, mergedHeaderModel, daySeparatorItem) @@ -269,54 +314,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.dimensionConverter = dimensionConverter - it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents)) - } - } - } - } - /** * Return true if added */ @@ -326,24 +323,29 @@ 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 - } + /** + * Return true if added + */ + private fun LoadingItem_.setVisibilityStateChangedListener(direction: Timeline.Direction): LoadingItem_ { + return onVisibilityStateChanged { model, view, visibilityState -> + if (visibilityState == VisibilityState.VISIBLE) { + callback?.onLoadMore(direction) } - - 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 searchPositionOfEvent(eventId: String?): Int? = synchronized(modelCache) { + return adapterPositionMapping[eventId] + } + + fun isLoadingForward() = showingForwardLoader + + 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/DefaultItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/DefaultItemFactory.kt index ec7d9e1622..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 @@ -17,18 +17,21 @@ 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.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory 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.util.MessageInformationDataFactory import javax.inject.Inject -class DefaultItemFactory @Inject constructor(private val avatarRenderer: AvatarRenderer, +class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: AvatarSizeProvider, + private val avatarRenderer: AvatarRenderer, private val informationDataFactory: MessageInformationDataFactory) { fun create(event: TimelineEvent, highlight: Boolean, + readMarkerVisible: Boolean, callback: TimelineEventController.Callback?, exception: Exception? = null): DefaultItem? { val text = if (exception == null) { @@ -37,12 +40,13 @@ class DefaultItemFactory @Inject constructor(private val avatarRenderer: AvatarR "an exception occurred when rendering the event ${event.root.eventId}" } - val informationData = informationDataFactory.create(event, null) + val informationData = informationDataFactory.create(event, null, readMarkerVisible) return DefaultItem_() + .leftGuideline(avatarSizeProvider.leftGuideline) + .highlighted(highlight) .text(text) .avatarRenderer(avatarRenderer) - .highlighted(highlight) .informationData(informationData) .baseCallback(callback) .readReceiptsCallback(callback) 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 9c4c59c46c..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 @@ -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,12 +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.core.utils.DimensionConverter -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.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 @@ -37,12 +35,13 @@ 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 dimensionConverter: DimensionConverter) { + private val avatarSizeProvider: AvatarSizeProvider, + private val attributesFactory: MessageItemAttributesFactory) { fun create(event: TimelineEvent, nextEvent: TimelineEvent?, highlight: Boolean, + readMarkerVisible: Boolean, callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? { event.root.eventId ?: return null @@ -58,7 +57,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,24 +65,14 @@ 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, readMarkerVisible) + val attributes = attributesFactory.create(null, informationData, callback) return MessageTextItem_() - .message(spannableStr) - .avatarRenderer(avatarRenderer) - .colorProvider(colorProvider) - .dimensionConverter(dimensionConverter) - .informationData(informationData) + .leftGuideline(avatarSizeProvider.leftGuideline) .highlighted(highlight) - .avatarCallback(callback) + .attributes(attributes) + .message(spannableStr) .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 index 58804f5f32..b38b02d2f5 100644 --- 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 @@ -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.events.model.Event import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.toModel @@ -26,6 +27,7 @@ import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.utils.DimensionConverter 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.senderAvatar import im.vector.riotx.features.home.room.detail.timeline.helper.senderName import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData @@ -35,11 +37,11 @@ import javax.inject.Inject class EncryptionItemFactory @Inject constructor(private val stringProvider: StringProvider, private val avatarRenderer: AvatarRenderer, - private val dimensionConverter: DimensionConverter) { + private val avatarSizeProvider: AvatarSizeProvider) { fun create(event: TimelineEvent, highlight: Boolean, - callback: TimelineEventController.BaseCallback?): NoticeItem? { + callback: TimelineEventController.Callback?): NoticeItem? { val text = buildNoticeText(event.root, event.senderName) ?: return null val informationData = MessageInformationData( @@ -50,13 +52,19 @@ class EncryptionItemFactory @Inject constructor(private val stringProvider: Stri memberName = event.senderName(), showInformation = false ) + val attributes = NoticeItem.Attributes( + avatarRenderer = avatarRenderer, + informationData = informationData, + noticeText = text, + itemLongClickListener = View.OnLongClickListener { view -> + callback?.onEventLongClicked(informationData, null, view) ?: false + }, + readReceiptsCallback = callback + ) return NoticeItem_() - .avatarRenderer(avatarRenderer) - .dimensionConverter(dimensionConverter) - .noticeText(text) - .informationData(informationData) + .leftGuideline(avatarSizeProvider.leftGuideline) .highlighted(highlight) - .baseCallback(callback) + .attributes(attributes) } private fun buildNoticeText(event: Event, senderName: String?): CharSequence? { 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..b5e5f50e03 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -0,0 +1,121 @@ +/* + * 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.core.di.ActiveSessionHolder +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.* +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 sessionHolder: ActiveSessionHolder, + 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, + readMarkerVisible: Boolean, + currentPosition: Int, + eventIdToHighlight: String?, + 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 { + 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.hasReadMarker && readMarkerVisible) { + showReadMarker = true + } + val senderAvatar = mergedEvent.senderAvatar() + val senderName = mergedEvent.senderName() + val data = MergedHeaderItem.Data( + userId = mergedEvent.root.senderId ?: "", + avatarUrl = senderAvatar, + memberName = senderName ?: "", + localId = mergedEvent.localId, + eventId = mergedEvent.root.eventId ?: "" + ) + mergedData.add(data) + } + 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() + }, + readMarkerId = readMarkerId, + showReadMarker = isCollapsed && showReadMarker, + readReceiptsCallback = callback + ) + MergedHeaderItem_() + .id(mergeId) + .leftGuideline(avatarSizeProvider.leftGuideline) + .highlighted(isCollapsed && highlighted) + .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 0dfa44563c..35ca7d7b9a 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 @@ -19,21 +19,28 @@ package im.vector.riotx.features.home.room.detail.timeline.factory import android.text.SpannableStringBuilder import android.text.Spanned import android.text.TextPaint -import android.text.style.AbsoluteSizeSpan import android.text.style.ClickableSpan import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan import android.view.View import dagger.Lazy import im.vector.matrix.android.api.permalinks.MatrixLinkify import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan import im.vector.matrix.android.api.session.events.model.RelationType import im.vector.matrix.android.api.session.events.model.toModel -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.MessageEmoteContent +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.MessageNoticeContent +import im.vector.matrix.android.api.session.room.model.message.MessageTextContent +import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent +import im.vector.matrix.android.api.session.room.model.message.getFileUrl 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 @@ -46,9 +53,11 @@ import im.vector.riotx.core.utils.isLocalFile 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.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 im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.VideoContentRenderer @@ -56,142 +65,120 @@ 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, - private val dimensionConverter: DimensionConverter) { + private val avatarSizeProvider: AvatarSizeProvider) { fun create(event: TimelineEvent, nextEvent: TimelineEvent?, highlight: Boolean, + readMarkerVisible: Boolean, callback: TimelineEventController.Callback? ): VectorEpoxyModel<*>? { event.root.eventId ?: return null - val informationData = messageInformationDataFactory.create(event, nextEvent) + val informationData = messageInformationDataFactory.create(event, nextEvent, readMarkerVisible) 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 buildNotHandledMessageItem(stringProvider.getString(R.string.malformed_message), - informationData, highlight, callback) + ?: //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 display it when debugging as a notice event - return noticeItemFactory.create(event, highlight, callback) + // This is an edit event, we should it when debugging as a notice event + return noticeItemFactory.create(event, highlight, readMarkerVisible, 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) - 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) - else -> buildNotHandledMessageItem("${messageContent.type} message events are not yet handled", - informationData, highlight, callback) + is MessageEmoteContent -> buildEmoteMessageItem(messageContent, + informationData, + highlight, + callback, + attributes) + is MessageTextContent -> buildTextMessageItem(messageContent, + 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) } } 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) - .dimensionConverter(dimensionConverter) + .attributes(attributes) .izLocalFile(messageContent.getFileUrl().isLocalFile()) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) - .informationData(informationData) .highlighted(highlight) - .avatarCallback(callback) - .readReceiptsCallback(callback) + .leftGuideline(avatarSizeProvider.leftGuideline) .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) - .dimensionConverter(dimensionConverter) + .attributes(attributes) + .leftGuideline(avatarSizeProvider.leftGuideline) .izLocalFile(messageContent.getFileUrl().isLocalFile()) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) - .informationData(informationData) .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) })) } - private fun buildNotHandledMessageItem(text: String, - informationData: MessageInformationData, - highlight: Boolean, - callback: TimelineEventController.Callback?): DefaultItem? { + 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) - .avatarRenderer(avatarRenderer) - .dimensionConverter(dimensionConverter) .highlighted(highlight) - .informationData(informationData) - .baseCallback(callback) - .readReceiptsCallback(callback) } 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( @@ -206,43 +193,30 @@ class MessageItemFactory @Inject constructor( rotation = messageContent.info?.rotation ) return MessageImageVideoItem_() - .avatarRenderer(avatarRenderer) - .colorProvider(colorProvider) - .dimensionConverter(dimensionConverter) + .attributes(attributes) + .leftGuideline(avatarSizeProvider.leftGuideline) .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, @@ -259,34 +233,21 @@ class MessageItemFactory @Inject constructor( ) return MessageImageVideoItem_() + .leftGuideline(avatarSizeProvider.leftGuideline) + .attributes(attributes) .imageContentRenderer(imageContentRenderer) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) - .avatarRenderer(avatarRenderer) - .colorProvider(colorProvider) - .dimensionConverter(dimensionConverter) .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()) @@ -303,26 +264,11 @@ class MessageItemFactory @Inject constructor( message(linkifiedBody) } } - .useBigFont(linkifiedBody.length <= MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT * 2 && containsOnlyEmojis(linkifiedBody.toString())) - .avatarRenderer(avatarRenderer) - .informationData(informationData) - .colorProvider(colorProvider) - .dimensionConverter(dimensionConverter) + .leftGuideline(avatarSizeProvider.leftGuideline) + .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, @@ -341,8 +287,7 @@ class MessageItemFactory @Inject constructor( editEnd, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) - // Note: text size is set to 14sp - spannable.setSpan(AbsoluteSizeSpan(dimensionConverter.spToPx(13)), editStart, editEnd, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + spannable.setSpan(RelativeSizeSpan(.9f), editStart, editEnd, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) spannable.setSpan(object : ClickableSpan() { override fun onClick(widget: View?) { callback?.onEditedDecorationClicked(informationData) @@ -352,16 +297,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 { @@ -372,35 +318,18 @@ class MessageItemFactory @Inject constructor( linkifyBody(formattedBody, callback) } return MessageTextItem_() - .avatarRenderer(avatarRenderer) + .leftGuideline(avatarSizeProvider.leftGuideline) + .attributes(attributes) .message(message) - .colorProvider(colorProvider) - .dimensionConverter(dimensionConverter) - .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" @@ -415,45 +344,18 @@ class MessageItemFactory @Inject constructor( message(message) } } - .avatarRenderer(avatarRenderer) - .colorProvider(colorProvider) - .dimensionConverter(dimensionConverter) - .informationData(informationData) + .leftGuideline(avatarSizeProvider.leftGuideline) + .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) - .dimensionConverter(dimensionConverter) - .informationData(informationData) + .leftGuideline(avatarSizeProvider.leftGuideline) + .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 { @@ -466,8 +368,4 @@ class MessageItemFactory @Inject constructor( VectorLinkify.addLinks(spannable, true) return spannable } - - companion object { - private const val MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT = 5 - } } \ No newline at end of file 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 76ac0e70f5..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 @@ -16,35 +16,42 @@ 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.core.utils.DimensionConverter 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 im.vector.riotx.features.home.room.detail.timeline.util.MessageInformationDataFactory import javax.inject.Inject class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEventFormatter, private val avatarRenderer: AvatarRenderer, private val informationDataFactory: MessageInformationDataFactory, - private val dimensionConverter: DimensionConverter) { + private val avatarSizeProvider: AvatarSizeProvider) { fun create(event: TimelineEvent, highlight: Boolean, + readMarkerVisible: Boolean, callback: TimelineEventController.Callback?): NoticeItem? { - val formattedText = eventFormatter.format(event) ?: return null - val informationData = informationDataFactory.create(event, null) + val formattedText = eventFormatter.format(event) ?: return null + val informationData = informationDataFactory.create(event, null, readMarkerVisible) + val attributes = NoticeItem.Attributes( + avatarRenderer = avatarRenderer, + informationData = informationData, + noticeText = formattedText, + itemLongClickListener = View.OnLongClickListener { view -> + callback?.onEventLongClicked(informationData, null, view) ?: false + }, + readReceiptsCallback = callback + ) return NoticeItem_() - .avatarRenderer(avatarRenderer) - .dimensionConverter(dimensionConverter) - .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/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index e9ce37f2b1..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 @@ -25,7 +25,6 @@ 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, @@ -34,12 +33,14 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me fun create(event: TimelineEvent, nextEvent: TimelineEvent?, eventIdToHighlight: String?, + 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, callback) + EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, readMarkerVisible, callback) // State and call EventType.STATE_ROOM_TOMBSTONE, EventType.STATE_ROOM_NAME, @@ -50,23 +51,23 @@ 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, readMarkerVisible, 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 - messageItemFactory.create(event, nextEvent, highlight, callback) + messageItemFactory.create(event, nextEvent, highlight, readMarkerVisible, callback) } else { - encryptedItemFactory.create(event, nextEvent, highlight, callback) + encryptedItemFactory.create(event, nextEvent, highlight, readMarkerVisible, callback) } } // Unhandled event types (yet) EventType.STATE_ROOM_THIRD_PARTY_INVITE, - EventType.STICKER -> defaultItemFactory.create(event, highlight, callback) + EventType.STICKER -> defaultItemFactory.create(event, highlight, readMarkerVisible, 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, readMarkerVisible, callback, e) } return (computedModel ?: EmptyItem_()) } 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..f55fd030b7 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/AvatarSizeProvider.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.riotx.features.home.room.detail.timeline.helper + +import im.vector.riotx.core.utils.DimensionConverter +import javax.inject.Inject + +class AvatarSizeProvider @Inject constructor(private val dimensionConverter: DimensionConverter) { + + private val avatarStyle = AvatarStyle.SMALL + + val leftGuideline: Int by lazy { + dimensionConverter.dpToPx(avatarStyle.avatarSizeDP + 8) + } + + val avatarSize: Int by lazy { + dimensionConverter.dpToPx(avatarStyle.avatarSizeDP) + } + + 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/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/util/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt similarity index 78% 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 bfb0848df3..8448ddc059 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 @@ -22,7 +24,6 @@ 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.features.home.room.detail.timeline.item.MessageInformationData @@ -38,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?): MessageInformationData { + fun create(event: TimelineEvent, nextEvent: TimelineEvent?, readMarkerVisible: Boolean): MessageInformationData { // Non nullability has been tested before val eventId = event.root.eventId!! @@ -62,6 +63,8 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId ?: "")) } + val displayReadMarker = readMarkerVisible && event.hasReadMarker + return MessageInformationData( eventId = eventId, senderId = event.root.senderId ?: "", @@ -85,7 +88,9 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses .map { ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs) } - .toList() + .toList(), + hasReadMarker = event.hasReadMarker, + displayReadMarker = displayReadMarker ) } } \ 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 new file mode 100644 index 0000000000..d69676cb2f --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt @@ -0,0 +1,63 @@ +/* + + * 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 avatarSizeProvider: AvatarSizeProvider, + private val emojiCompatFontProvider: EmojiCompatFontProvider) { + + fun create(messageContent: MessageContent?, + informationData: MessageInformationData, + callback: TimelineEventController.Callback?): AbsMessageItem.Attributes { + + return AbsMessageItem.Attributes( + avatarSize = avatarSizeProvider.avatarSize, + 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 7307e0674d..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,30 +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()) { - //redacted events have empty content but are displayable - return root.unsignedData?.redactedEvent != null - } - //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 @@ -132,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 fa132be365..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 @@ -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.utils.DebouncedClickListener import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController @@ -41,78 +42,62 @@ 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 onReadMarkerLongBound(isDisplayed: Boolean) { + attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.informationData.eventId, isDisplayed) + } + } + 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 = dimensionConverter.dpToPx(avatarStyle.avatarSizeDP) - height = size - width = size + height = attributes.avatarSize + width = attributes.avatarSize } holder.avatarImageView.visibility = View.VISIBLE holder.avatarImageView.setOnClickListener(_avatarClickListener) 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.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.avatarImageView.setOnLongClickListener(attributes.itemLongClickListener) + holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener) } else { holder.avatarImageView.setOnClickListener(null) holder.memberNameView.setOnClickListener(null) @@ -122,14 +107,24 @@ abstract class AbsMessageItem : BaseEventItem() { holder.avatarImageView.setOnLongClickListener(null) holder.memberNameView.setOnLongClickListener(null) } + holder.view.setOnClickListener(attributes.itemClickListener) + holder.view.setOnLongClickListener(attributes.itemLongClickListener) - holder.view.setOnClickListener(cellClickListener) - holder.view.setOnLongClickListener(longClickListener) + holder.readReceiptsView.render( + attributes.informationData.readReceipts, + attributes.avatarRenderer, + _readReceiptsClickListener + ) + holder.readMarkerView.bindView( + attributes.informationData.eventId, + attributes.informationData.hasReadMarker, + attributes.informationData.displayReadMarker, + _readMarkerCallback + ) - 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 if (holder.reactionFlowHelper == null) { @@ -140,7 +135,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 @@ -148,7 +143,6 @@ abstract class AbsMessageItem : BaseEventItem() { idToRefInFlow.add(reactionButton.id) reactionButton.reactionString = reaction.key reactionButton.reactionCount = reaction.count - //reactionButton.emojiTypeFace = emojiTypeFace reactionButton.setChecked(reaction.addedByMe) reactionButton.isEnabled = reaction.synced } @@ -159,19 +153,28 @@ 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 } + override fun getEventIds(): List { + return listOf(attributes.informationData.eventId) + } + 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() } abstract class Holder(@IdRes stubId: Int) : BaseHolder(stubId) { @@ -182,4 +185,21 @@ abstract class AbsMessageItem : BaseEventItem() { var reactionFlowHelper: Flow? = null } + /** + * 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, + 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 + ) + } \ 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 561059de63..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 @@ -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.DimensionConverter @@ -32,28 +33,32 @@ import im.vector.riotx.core.utils.DimensionConverter */ 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 @EpoxyAttribute lateinit var dimensionConverter: DimensionConverter override fun bind(holder: H) { super.bind(holder) - //optimize? - val px = dimensionConverter.dpToPx(avatarStyle.avatarSizeDP + 8) - holder.leftGuideline.setGuidelineBegin(px) - + holder.leftGuideline.setGuidelineBegin(leftGuideline) holder.checkableBackground.isChecked = highlighted } + /** + * 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) 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) @@ -65,13 +70,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/DefaultItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/DefaultItem.kt index b4919494f6..48b12b3898 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 @@ -30,10 +30,8 @@ abstract class DefaultItem : BaseEventItem() { @EpoxyAttribute lateinit var informationData: MessageInformationData - @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer - @EpoxyAttribute var baseCallback: TimelineEventController.BaseCallback? = null @@ -53,11 +51,14 @@ abstract class DefaultItem : BaseEventItem() { override fun bind(holder: Holder) { holder.messageView.text = text - holder.view.setOnLongClickListener(longClickListener) holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener) } + override fun getEventIds(): List { + return listOf(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/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 03dd5a8a4a..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 @@ -22,29 +22,28 @@ import android.widget.ImageView import android.widget.TextView import androidx.core.view.children 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() { -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) + private val distinctMergeData by lazy { + attributes.mergeData.distinctBy { it.userId } } - override fun getDefaultLayout(): Int { - return R.layout.item_timeline_event_base_noinfo - } + private val _readMarkerCallback = object : ReadMarkerView.Callback { - override fun createNewHolder(): Holder { - return Holder() + override fun onReadMarkerLongBound(isDisplayed: Boolean) { + attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.readMarkerId ?: "", isDisplayed) + } } override fun getViewType() = STUB_ID @@ -52,10 +51,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 @@ -63,7 +62,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 } @@ -76,25 +75,48 @@ data class MergedHeaderItem(private val isCollapsed: Boolean, holder.separatorView.visibility = View.VISIBLE holder.expandView.setText(R.string.merged_events_collapse) } - // 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) + } + + + override fun getEventIds(): List { + return attributes.mergeData.map { it.eventId } } data class Data( - val eventId: Long, + val localId: Long, + val eventId: String, val userId: String, val memberName: String, val avatarUrl: String? ) - class Holder : BaseHolder(STUB_ID) { + 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 + ) + class Holder : BaseHolder(STUB_ID) { val expandView by bind(R.id.itemMergedExpandTextView) val summaryView by bind(R.id.itemMergedSummaryTextView) val separatorView by bind(R.id.itemMergedSeparatorView) val avatarListView by bind(R.id.itemMergedAvatarListView) - } companion object { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageFileItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageFileItem.kt index 56d6a33bc7..a32898fb20 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageFileItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageFileItem.kt @@ -46,8 +46,8 @@ abstract class MessageFileItem : AbsMessageItem() { override fun bind(holder: Holder) { super.bind(holder) renderSendState(holder.fileLayout, holder.filenameView) - if (!informationData.sendState.hasFailed()) { - contentUploadStateTrackerBinder.bind(informationData.eventId, izLocalFile, holder.progressLayout) + if (!attributes.informationData.sendState.hasFailed()) { + contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, izLocalFile, holder.progressLayout) } else { holder.progressLayout.isVisible = false } @@ -59,8 +59,7 @@ abstract class MessageFileItem : AbsMessageItem() { override fun unbind(holder: Holder) { super.unbind(holder) - - contentUploadStateTrackerBinder.unbind(informationData.eventId) + contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId) } override fun getViewType() = STUB_ID 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 50e263267a..de6c6980a7 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 @@ -44,23 +44,23 @@ abstract class MessageImageVideoItem : AbsMessageItem? = null, val hasBeenEdited: Boolean = false, val hasPendingEdits: Boolean = false, - val readReceipts: List = emptyList() + 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/MessageTextItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt index 3d682cdde3..2ba2337242 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageTextItem.kt @@ -78,8 +78,8 @@ abstract class MessageTextItem : 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 2985049710..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 @@ -22,6 +22,7 @@ import android.widget.TextView 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.utils.DebouncedClickListener import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController @@ -30,40 +31,47 @@ 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 - - private var longClickListener = View.OnLongClickListener { - return@OnLongClickListener baseCallback?.onEventLongClicked(informationData, null, it) == true - } - - @EpoxyAttribute - var readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null + lateinit var attributes: Attributes private val _readReceiptsClickListener = DebouncedClickListener(View.OnClickListener { - readReceiptsCallback?.onReadReceiptsClicked(informationData.readReceipts) + attributes.readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts) }) + private val _readMarkerCallback = object : ReadMarkerView.Callback { + + override fun onReadMarkerLongBound(isDisplayed: Boolean) { + attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.informationData.eventId, isDisplayed) + } + } + 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.view.setOnLongClickListener(attributes.itemLongClickListener) + holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) + holder.readMarkerView.bindView( + attributes.informationData.eventId, + attributes.informationData.hasReadMarker, + attributes.informationData.displayReadMarker, + _readMarkerCallback + ) + } + + override fun unbind(holder: Holder) { + holder.readMarkerView.unbind() + super.unbind(holder) + } + + + override fun getEventIds(): List { + return listOf(attributes.informationData.eventId) } override fun getViewType() = STUB_ID @@ -73,6 +81,14 @@ abstract class NoticeItem : BaseEventItem() { val noticeTextView by bind(R.id.itemNoticeTextView) } + data class Attributes( + val avatarRenderer: AvatarRenderer, + val informationData: MessageInformationData, + val noticeText: CharSequence, + val itemLongClickListener: View.OnLongClickListener? = null, + val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null + ) + companion object { private const val STUB_ID = R.id.messageContentNoticeStub } 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/drawable-hdpi/arrow_up_circle.png b/vector/src/main/res/drawable-hdpi/arrow_up_circle.png new file mode 100755 index 0000000000..c7fba081c1 Binary files /dev/null and b/vector/src/main/res/drawable-hdpi/arrow_up_circle.png differ diff --git a/vector/src/main/res/drawable-hdpi/chevron_down.png b/vector/src/main/res/drawable-hdpi/chevron_down.png new file mode 100755 index 0000000000..a18addf67f Binary files /dev/null and b/vector/src/main/res/drawable-hdpi/chevron_down.png differ 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 0000000000..aad94e9a4e Binary files /dev/null and b/vector/src/main/res/drawable-mdpi/arrow_up_circle.png differ diff --git a/vector/src/main/res/drawable-mdpi/chevron_down.png b/vector/src/main/res/drawable-mdpi/chevron_down.png new file mode 100755 index 0000000000..26852cbbee Binary files /dev/null and b/vector/src/main/res/drawable-mdpi/chevron_down.png differ diff --git a/vector/src/main/res/drawable-xhdpi/arrow_up_circle.png b/vector/src/main/res/drawable-xhdpi/arrow_up_circle.png new file mode 100755 index 0000000000..8973128ccc Binary files /dev/null and b/vector/src/main/res/drawable-xhdpi/arrow_up_circle.png differ diff --git a/vector/src/main/res/drawable-xhdpi/chevron_down.png b/vector/src/main/res/drawable-xhdpi/chevron_down.png new file mode 100755 index 0000000000..c22557abf2 Binary files /dev/null and b/vector/src/main/res/drawable-xhdpi/chevron_down.png differ diff --git a/vector/src/main/res/drawable-xxhdpi/arrow_up_circle.png b/vector/src/main/res/drawable-xxhdpi/arrow_up_circle.png new file mode 100755 index 0000000000..c13179d220 Binary files /dev/null and b/vector/src/main/res/drawable-xxhdpi/arrow_up_circle.png differ diff --git a/vector/src/main/res/drawable-xxhdpi/chevron_down.png b/vector/src/main/res/drawable-xxhdpi/chevron_down.png new file mode 100755 index 0000000000..0a44d0e1aa Binary files /dev/null and b/vector/src/main/res/drawable-xxhdpi/chevron_down.png differ 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 0000000000..61fd2b1e48 Binary files /dev/null and b/vector/src/main/res/drawable-xxxhdpi/arrow_up_circle.png differ 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 0000000000..31b1eb464a Binary files /dev/null and b/vector/src/main/res/drawable-xxxhdpi/chevron_down.png differ diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml index e0817a3682..a9385f4eeb 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/syncStateView" /> + + + + \ 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 8e42804c59..4eb9be0b9f 100644 --- a/vector/src/main/res/layout/item_timeline_event_base.xml +++ b/vector/src/main/res/layout/item_timeline_event_base.xml @@ -122,17 +122,26 @@ - + + - + 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 3f82c8db89..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,10 +58,21 @@ android:layout_height="wrap_content" android:layout_marginEnd="8dp" android:layout_marginBottom="4dp" - android:visibility="gone" + 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..aac22b3311 --- /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 @@ + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/values/attrs_badge_fab.xml b/vector/src/main/res/values/attrs_badge_fab.xml new file mode 100644 index 0000000000..c9be5175e0 --- /dev/null +++ b/vector/src/main/res/values/attrs_badge_fab.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file