Merge pull request #592 from vector-im/feature/read_marker

Feature/read marker
This commit is contained in:
ganfra 2019-10-01 13:55:09 +02:00 committed by GitHub
commit 716999eec6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
96 changed files with 2638 additions and 1059 deletions

View file

@ -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:

View file

@ -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

View file

@ -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<RoomSummary> {
return room.liveRoomSummary().asObservable()
return room.getRoomSummaryLive().asObservable()
}
fun liveRoomMemberIds(): Observable<List<String>> {
@ -40,7 +41,15 @@ class RxRoom(private val room: Room) {
}
fun liveTimelineEvent(eventId: String): Observable<TimelineEvent> {
return room.liveTimeLineEvent(eventId).asObservable()
return room.getTimeLineEventLive(eventId).asObservable()
}
fun liveReadMarker(): Observable<Optional<String>> {
return room.getReadMarkerLive().asObservable()
}
fun liveReadReceipt(): Observable<Optional<String>> {
return room.getMyReadReceiptLive().asObservable()
}
fun loadRoomMembersIfNeeded(): Single<Unit> = Single.create {

View file

@ -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<RoomSummary>
fun getRoomSummaryLive(): LiveData<RoomSummary>
fun roomSummary(): RoomSummary?

View file

@ -38,9 +38,14 @@ data class RoomSummary(
val tags: List<RoomTag> = emptyList(),
val membership: Membership = Membership.NONE,
val versioningState: VersioningState = VersioningState.NONE,
val readMarkerId: String? = null,
val userDrafts: List<UserDraft> = emptyList()
) {
val isVersioned: Boolean
get() = versioningState != VersioningState.NONE
}
val hasNewMessages: Boolean
get() = notificationCount != 0
}

View file

@ -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<Unit>)
/**
* 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<Optional<String>>
/**
* Returns a live read receipt id for the room.
*/
fun getMyReadReceiptLive(): LiveData<Optional<String>>
/**
* Returns a live list of read receipts for a given event
* @param eventId: the event
*/
fun getEventReadReceiptsLive(eventId: String): LiveData<List<ReadReceipt>>
}

View file

@ -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 {
/**

View file

@ -40,7 +40,8 @@ data class TimelineEvent(
val isUniqueDisplayName: Boolean,
val senderAvatar: String?,
val annotations: EventAnnotationsSummary? = null,
val readReceipts: List<ReadReceipt> = emptyList()
val readReceipts: List<ReadReceipt> = emptyList(),
val hasReadMarker: Boolean = false
) {
val metadata = HashMap<String, Any>()

View file

@ -36,5 +36,5 @@ interface TimelineService {
fun getTimeLineEvent(eventId: String): TimelineEvent?
fun liveTimeLineEvent(eventId: String): LiveData<TimelineEvent>
fun getTimeLineEventLive(eventId: String): LiveData<TimelineEvent>
}

View file

@ -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)

View file

@ -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()
)
}

View file

@ -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
)
}

View file

@ -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<TimelineEventEntity>? = null
companion object
}

View file

@ -35,6 +35,7 @@ internal open class RoomSummaryEntity(@PrimaryKey var roomId: String = "",
var otherMemberIds: RealmList<String> = RealmList(),
var notificationCount: Int = 0,
var highlightCount: Int = 0,
var readMarkerId: String? = null,
var hasUnreadMessages: Boolean = false,
var tags: RealmList<RoomTagEntity> = RealmList(),
var userDrafts: UserDraftsEntity? = null

View file

@ -44,6 +44,7 @@ import io.realm.annotations.RealmModule
PusherEntity::class,
PusherDataEntity::class,
ReadReceiptsSummaryEntity::class,
ReadMarkerEntity::class,
UserDraftsEntity::class,
DraftEntity::class,
HomeServerCapabilitiesEntity::class

View file

@ -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")

View file

@ -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

View file

@ -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<ReadMarkerEntity> {
val query = realm.where<ReadMarkerEntity>()
.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)
}

View file

@ -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<ReadReceiptEntity> {
@ -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<ReadReceiptEntity> {
return realm.where<ReadReceiptEntity>()
.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"

View file

@ -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<ReadReceiptsSummaryEntity> {
val query = realm.where<ReadReceiptsSummaryEntity>()
if (roomId != null) {
query.equalTo(ReadReceiptsSummaryEntityFields.ROOM_ID, roomId)
}
return query
internal fun ReadReceiptsSummaryEntity.Companion.whereInRoom(realm: Realm, roomId: String): RealmQuery<ReadReceiptsSummaryEntity> {
return realm.where<ReadReceiptsSummaryEntity>()
.equalTo(ReadReceiptsSummaryEntityFields.ROOM_ID, roomId)
}

View file

@ -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<RoomSummaryEntity> {
return RoomSummaryEntity.where(realm)
.equalTo(RoomSummaryEntityFields.IS_DIRECT, true)

View file

@ -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<TimelineEventEntity> {
internal fun TimelineEventEntity.Companion.where(realm: Realm, roomId: String, eventId: String): RealmQuery<TimelineEventEntity> {
return realm.where<TimelineEventEntity>()
.equalTo(TimelineEventEntityFields.ROOM_ID, roomId)
.equalTo(TimelineEventEntityFields.EVENT_ID, eventId)
}
internal fun TimelineEventEntity.Companion.where(realm: Realm, eventIds: List<String>): RealmQuery<TimelineEventEntity> {
internal fun TimelineEventEntity.Companion.where(realm: Realm, roomId: String, eventIds: List<String>): RealmQuery<TimelineEventEntity> {
return realm.where<TimelineEventEntity>()
.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<TimelineEventEntity>()
.equalTo(TimelineEventEntityFields.ROOM_ID, roomId)
.`in`(TimelineEventEntityFields.ROOT.SEND_STATE_STR,sendStatesStr)
.`in`(TimelineEventEntityFields.ROOT.SEND_STATE_STR, sendStatesStr)
.findAll()
}

View file

@ -24,4 +24,4 @@ import javax.inject.Scope
@Scope
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class MatrixScope
internal annotation class MatrixScope

View file

@ -21,4 +21,4 @@ import javax.inject.Scope
@Scope
@MustBeDocumented
@Retention(AnnotationRetention.RUNTIME)
annotation class SessionScope
internal annotation class SessionScope

View file

@ -56,7 +56,7 @@ internal class DefaultRoom @Inject constructor(override val roomId: String,
RelationService by relationService,
MembershipService by roomMembersService {
override fun liveRoomSummary(): LiveData<RoomSummary> {
override fun getRoomSummaryLive(): LiveData<RoomSummary> {
val liveRealmData = RealmLiveData<RoomSummaryEntity>(monarchy.realmConfiguration) { realm ->
RoomSummaryEntity.where(realm, roomId).isNotEmpty(RoomSummaryEntityFields.DISPLAY_NAME)
}

View file

@ -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

View file

@ -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<Optional<String>> {
val liveRealmData = RealmLiveData(monarchy.realmConfiguration) { realm ->
ReadMarkerEntity.where(realm, roomId)
}
return Transformations.map(liveRealmData) { results ->
Optional.from(results.firstOrNull()?.eventId)
}
}
override fun getMyReadReceiptLive(): LiveData<Optional<String>> {
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<List<ReadReceipt>> {
val liveEntity = RealmLiveData(monarchy.realmConfiguration) { realm ->
ReadReceiptsSummaryEntity.where(realm, eventId)

View file

@ -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
)

View file

@ -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<String, String>()
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<Unit> {
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<String, String>) {
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
}
}

View file

@ -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 {

View file

@ -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<TimelineEventEntity>
private lateinit var nonFilteredEvents: RealmResults<TimelineEventEntity>
private lateinit var filteredEvents: RealmResults<TimelineEventEntity>
private lateinit var eventRelations: RealmResults<EventAnnotationsSummaryEntity>
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<TimelineEvent>(ArrayList())
private val builtEventsIdMap = Collections.synchronizedMap(HashMap<String, Int>())
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<RealmResults<TimelineEventEntity>> { 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<RealmResults<EventAnnotationsSummaryEntity>> { collection, changeSet ->
private val relationsListener = OrderedRealmCollectionChangeListener<RealmResults<EventAnnotationsSummaryEntity>> { 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<ReadReceipt>): 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<TimelineEventEntity>) {
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<TimelineEvent> {
val sendingEvents = ArrayList<TimelineEvent>()
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<TokenChunkEventPersistor.Result> {
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<TimelineEventEntity> {
val offsetQuery = liveEvents.where()
count: Long,
strict: Boolean): RealmResults<TimelineEventEntity> {
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<TimelineEventEntity>.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<TimelineEventEntity>.filterEventsWithSettings(): RealmQuery<TimelineEventEntity> {
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
)

View file

@ -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<TimelineEvent> {
override fun getTimeLineEventLive(eventId: String): LiveData<TimelineEvent> {
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) }

View file

@ -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<ReadMarkerEntity>? = null
private lateinit var filteredEvents: RealmResults<TimelineEventEntity>
private lateinit var nonFilteredEvents: RealmResults<TimelineEventEntity>
private lateinit var delegate: Delegate
private val readMarkerListener = OrderedRealmCollectionChangeListener<RealmResults<ReadMarkerEntity>> { 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<TimelineEventEntity>,
nonFilteredEvents: RealmResults<TimelineEventEntity>,
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<ReadMarkerEntity>.filterReceiptsWithSettings(): RealmQuery<ReadMarkerEntity> {
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
}
}

View file

@ -49,15 +49,19 @@ internal class TimelineHiddenReadReceipts constructor(private val readReceiptsSu
private val correctedReadReceiptsByEvent = HashMap<String, MutableList<ReadReceipt>>()
private lateinit var hiddenReadReceipts: RealmResults<ReadReceiptsSummaryEntity>
private lateinit var liveEvents: RealmResults<TimelineEventEntity>
private lateinit var nonFilteredEvents: RealmResults<TimelineEventEntity>
private lateinit var filteredEvents: RealmResults<TimelineEventEntity>
private lateinit var delegate: Delegate
private val hiddenReadReceiptsListener = OrderedRealmCollectionChangeListener<RealmResults<ReadReceiptsSummaryEntity>> { 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<TimelineEventEntity>, delegate: Delegate) {
this.liveEvents = liveEvents
fun start(realm: Realm,
filteredEvents: RealmResults<TimelineEventEntity>,
nonFilteredEvents: RealmResults<TimelineEventEntity>,
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).

View file

@ -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<String>(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

View file

@ -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

View file

@ -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 }
}
}

View file

@ -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<RoomTagContent>() }
.forEach { roomTagHandler.handle(realm, roomId, it) }
for (event in accountData.events) {
val eventType = event.getClearType()
if (eventType == EventType.TAG) {
val content = event.getClearContent().toModel<RoomTagContent>()
roomTagHandler.handle(realm, roomId, content)
} else if (eventType == EventType.FULLY_READ) {
val content = event.getClearContent().toModel<FullyReadContent>()
roomFullyReadHandler.handle(realm, roomId, content)
}
}
}
}

View file

@ -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()
)

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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<String, Runnable>()
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)
}
}

View file

@ -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()
)

View file

@ -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

View file

@ -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?
)

View file

@ -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?)
}
}

View file

@ -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<MediaFile>) : 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()
}

View file

@ -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)
}
}

View file

@ -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<RoomDetailActions.EventDisplayed>()
private val invisibleEventsObservable = BehaviorRelay.create<RoomDetailActions.TimelineEventTurnsInvisible>()
private val visibleEventsObservable = BehaviorRelay.create<RoomDetailActions.TimelineEventTurnsVisible>()
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<RoomTombstoneContent>() ?: return
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>()
?: 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<MessageContent>()?.relatesTo?.inReplyTo?.eventId
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.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<Unit> {})
}
private fun handleMarkAllAsRead() {
room.markAllAsRead(object : MatrixCallback<Any> {})
}
private fun observeSyncState() {
session.rx()
.liveSyncState()

View file

@ -51,7 +51,8 @@ data class RoomDetailViewState(
val isEncrypted: Boolean = false,
val tombstoneEvent: Event? = null,
val tombstoneEventHandling: Async<String> = 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)

View file

@ -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<String?>()
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)
}

View file

@ -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)
}
}

View file

@ -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<ReadReceiptData>)
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<Long>()
private val mergeItemCollapseStates = HashMap<Long, Boolean>()
private var showingForwardLoader = false
// Map eventId to adapter position
private val adapterPositionMapping = HashMap<String, Int>()
private val modelCache = arrayListOf<CacheItemData?>()
private var currentSnapshot: List<TimelineEvent> = emptyList()
private var inSubmitList: Boolean = false
private var timeline: Timeline? = null
var callback: Callback? = null
private val listUpdateCallback = object : ListUpdateCallback {
override fun onChanged(position: Int, count: Int, payload: Any?) {
@ -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<EpoxyModel<*>>) {
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<TimelineEvent>): 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<TimelineEvent>,
addDaySeparator: Boolean,
currentPosition: Int): MergedHeaderItem? {
return if (!event.canBeMerged() || (nextEvent?.root?.getClearType() == event.root.getClearType() && !addDaySeparator)) {
null
} else {
val prevSameTypeEvents = items.prevSameTypeEvents(currentPosition, 2)
if (prevSameTypeEvents.isEmpty()) {
null
} else {
val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed()
val mergedData = mergedEvents.map { mergedEvent ->
val senderAvatar = mergedEvent.senderAvatar()
val senderName = mergedEvent.senderName()
MergedHeaderItem.Data(
userId = mergedEvent.root.senderId ?: "",
avatarUrl = senderAvatar,
memberName = senderName ?: "",
eventId = mergedEvent.localId
)
}
val mergedEventIds = mergedEvents.map { it.localId }
// We try to find if one of the item id were used as mergeItemCollapseStates key
// => handle case where paginating from mergeable events and we get more
val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull()
val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey)
?: true
val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState }
if (isCollapsed) {
collapsedEventIds.addAll(mergedEventIds)
} else {
collapsedEventIds.removeAll(mergedEventIds)
}
val mergeId = mergedEventIds.joinToString(separator = "_") { it.toString() }
MergedHeaderItem(isCollapsed, mergeId, mergedData, avatarRenderer) {
mergeItemCollapseStates[event.localId] = it
requestModelBuild()
}.also {
it.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
)
}

View file

@ -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)

View file

@ -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
}

View file

@ -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? {

View file

@ -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<Long>()
private val mergeItemCollapseStates = HashMap<Long, Boolean>()
fun create(event: TimelineEvent,
nextEvent: TimelineEvent?,
items: List<TimelineEvent>,
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<MergedHeaderItem.Data>(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)
}
}

View file

@ -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<EventHtmlRenderer>,
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<EncryptedEventContent>()?.relatesTo?.type == RelationType.REPLACE
|| event.isEncrypted() && event.root.content.toModel<EncryptedEventContent>()?.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<Event>()
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
}
}

View file

@ -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)
}

View file

@ -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_())
}

View file

@ -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)
}
}
}

View file

@ -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 isnt 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)
}
}
}

View file

@ -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
)
}
}

View file

@ -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
)
}
}

View file

@ -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<MessageContent>()?.relatesTo?.type == RelationType.REPLACE) {
return false
}
return true
}
//
//fun List<TimelineEvent>.filterDisplayableEvents(): List<TimelineEvent> {
// 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<TimelineEvent>.prevSameTypeEvents(index: Int, minSize: Int): List<Timel
.reversed()
}
fun List<TimelineEvent>.nextDisplayableEvent(index: Int, showHiddenEvent: Boolean): TimelineEvent? {
fun List<TimelineEvent>.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()
}
}

View file

@ -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) }
}
}

View file

@ -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<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
@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<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
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<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
//clear all reaction buttons (but not the Flow helper!)
holder.reactionWrapper?.children?.forEach { (it as? ReactionButton)?.isGone = true }
val idToRefInFlow = ArrayList<Int>()
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<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
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<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
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<String> {
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<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
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
)
}

View file

@ -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<H : BaseEventItem.BaseHolder> : VectorEpoxyModel<H>() {
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<String>
abstract class BaseHolder(@IdRes val stubId: Int) : VectorEpoxyHolder() {
val leftGuideline by bind<Guideline>(R.id.messageStartGuideline)
val checkableBackground by bind<CheckableView>(R.id.messageSelectedBackground)
val readReceiptsView by bind<ReadReceiptsView>(R.id.readReceiptsView)
val readMarkerView by bind<ReadMarkerView>(R.id.readMarkerView)
override fun bindView(itemView: View) {
super.bindView(itemView)
@ -65,13 +70,4 @@ abstract class BaseEventItem<H : BaseEventItem.BaseHolder> : VectorEpoxyModel<H>
}
}
companion object {
enum class AvatarStyle(val avatarSizeDP: Int) {
BIG(50),
MEDIUM(40),
SMALL(30),
NONE(0)
}
}
}

View file

@ -30,10 +30,8 @@ abstract class DefaultItem : BaseEventItem<DefaultItem.Holder>() {
@EpoxyAttribute
lateinit var informationData: MessageInformationData
@EpoxyAttribute
lateinit var avatarRenderer: AvatarRenderer
@EpoxyAttribute
var baseCallback: TimelineEventController.BaseCallback? = null
@ -53,11 +51,14 @@ abstract class DefaultItem : BaseEventItem<DefaultItem.Holder>() {
override fun bind(holder: Holder) {
holder.messageView.text = text
holder.view.setOnLongClickListener(longClickListener)
holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener)
}
override fun getEventIds(): List<String> {
return listOf(informationData.eventId)
}
override fun getViewType() = STUB_ID
class Holder : BaseHolder(STUB_ID) {

View file

@ -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

View file

@ -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<MergedHeaderItem.Holder>() {
data class MergedHeaderItem(private val isCollapsed: Boolean,
private val mergeId: String,
private val mergeData: List<Data>,
private val avatarRenderer: AvatarRenderer,
private val onCollapsedStateChanged: (Boolean) -> Unit
) : BaseEventItem<MergedHeaderItem.Holder>() {
@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<String> {
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<Data>,
val avatarRenderer: AvatarRenderer,
val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null,
val onCollapsedStateChanged: (Boolean) -> Unit
)
class Holder : BaseHolder(STUB_ID) {
val expandView by bind<TextView>(R.id.itemMergedExpandTextView)
val summaryView by bind<TextView>(R.id.itemMergedSummaryTextView)
val separatorView by bind<View>(R.id.itemMergedSeparatorView)
val avatarListView by bind<ViewGroup>(R.id.itemMergedAvatarListView)
}
companion object {

View file

@ -46,8 +46,8 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
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<MessageFileItem.Holder>() {
override fun unbind(holder: Holder) {
super.unbind(holder)
contentUploadStateTrackerBinder.unbind(informationData.eventId)
contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId)
}
override fun getViewType() = STUB_ID

View file

@ -44,23 +44,23 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
override fun bind(holder: Holder) {
super.bind(holder)
imageContentRenderer.render(mediaData, ImageContentRenderer.Mode.THUMBNAIL, holder.imageView)
if (!informationData.sendState.hasFailed()) {
contentUploadStateTrackerBinder.bind(informationData.eventId, mediaData.isLocalFile(), holder.progressLayout)
if (!attributes.informationData.sendState.hasFailed()) {
contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, mediaData.isLocalFile(), holder.progressLayout)
} else {
holder.progressLayout.isVisible = false
}
holder.imageView.setOnClickListener(clickListener)
holder.imageView.setOnLongClickListener(longClickListener)
holder.imageView.setOnLongClickListener(attributes.itemLongClickListener)
ViewCompat.setTransitionName(holder.imageView, "imagePreview_${id()}")
holder.mediaContentView.setOnClickListener(cellClickListener)
holder.mediaContentView.setOnLongClickListener(longClickListener)
holder.mediaContentView.setOnClickListener(attributes.itemClickListener)
holder.mediaContentView.setOnLongClickListener(attributes.itemLongClickListener)
// The sending state color will be apply to the progress text
renderSendState(holder.imageView, null, holder.failedToSendIndicator)
holder.playContentView.visibility = if (playable) View.VISIBLE else View.GONE
}
override fun unbind(holder: Holder) {
contentUploadStateTrackerBinder.unbind(informationData.eventId)
contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId)
super.unbind(holder)
}

View file

@ -33,7 +33,9 @@ data class MessageInformationData(
val orderedReactionList: List<ReactionInfoData>? = null,
val hasBeenEdited: Boolean = false,
val hasPendingEdits: Boolean = false,
val readReceipts: List<ReadReceiptData> = emptyList()
val readReceipts: List<ReadReceiptData> = emptyList(),
val hasReadMarker: Boolean = false,
val displayReadMarker: Boolean = false
) : Parcelable

View file

@ -78,8 +78,8 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
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) }
}

View file

@ -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<NoticeItem.Holder>() {
@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<String> {
return listOf(attributes.informationData.eventId)
}
override fun getViewType() = STUB_ID
@ -73,6 +81,14 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
val noticeTextView by bind<TextView>(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
}

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<scale
android:duration="1500"
android:fromXScale="1"
android:fromYScale="1"
android:pivotX="50%p"
@ -10,7 +9,6 @@
android:toYScale="0" />
<alpha
android:duration="1500"
android:fromAlpha="1"
android:toAlpha="0" />
</set>

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

View file

@ -6,6 +6,29 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<FrameLayout
android:id="@+id/syncProgressBarWrap"
android:layout_width="match_parent"
android:layout_height="3dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomToolbar"
tools:visibility="visible">
<ProgressBar
android:id="@+id/syncProgressBar"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="match_parent"
android:layout_height="14dp"
android:layout_gravity="center"
android:background="?riotx_header_panel_background"
android:indeterminate="true"
android:visibility="gone"
tools:visibility="visible" />
</FrameLayout>
<!-- Trick to remove surrounding padding (clip frome wrapping frame) -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/roomToolbar"
style="@style/VectorToolbarStyle"
@ -71,6 +94,13 @@
</androidx.appcompat.widget.Toolbar>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/recyclerViewBarrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="composerLayout,notificationAreaView" />
<im.vector.riotx.features.sync.widget.SyncStateView
android:id="@+id/syncStateView"
android:layout_width="match_parent"
@ -90,22 +120,14 @@
app:layout_constraintTop_toBottomOf="@id/syncStateView"
tools:listitem="@layout/item_timeline_event_base" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/recyclerViewBarrier"
android:layout_width="wrap_content"
<im.vector.riotx.core.ui.views.JumpToReadMarkerView
android:id="@+id/jumpToReadMarkerView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="composerLayout,notificationAreaView" />
<im.vector.riotx.features.home.room.detail.composer.TextComposerView
android:id="@+id/composerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?riotx_background"
android:transitionName="composer"
app:layout_constraintBottom_toBottomOf="parent"
android:visibility="invisible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/syncStateView" />
<im.vector.riotx.core.ui.views.NotificationAreaView
android:id="@+id/notificationAreaView"
@ -117,6 +139,16 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<im.vector.riotx.features.home.room.detail.composer.TextComposerView
android:id="@+id/composerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?riotx_background"
android:transitionName="composer"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<im.vector.riotx.features.invite.VectorInviteView
android:id="@+id/inviteView"
android:layout_width="0dp"
@ -129,5 +161,21 @@
app:layout_constraintTop_toBottomOf="@+id/roomToolbar"
tools:visibility="visible" />
<im.vector.riotx.core.platform.BadgeFloatingActionButton
android:id="@+id/jumpToBottomView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:src="@drawable/chevron_down"
app:backgroundTint="#FFFFFF"
app:badgeBackgroundColor="@color/riotx_accent"
app:badgeTextColor="@color/white"
app:badgeTextPadding="2dp"
app:badgeTextSize="10sp"
app:layout_constraintBottom_toTopOf="@id/composerLayout"
app:layout_constraintEnd_toEndOf="parent"
app:maxImageSize="16dp"
app:tint="@color/black" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -122,17 +122,26 @@
</ViewStub>
<im.vector.riotx.core.ui.views.ReadReceiptsView
android:id="@+id/readReceiptsView"
android:layout_width="wrap_content"
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" />
<im.vector.riotx.core.ui.views.ReadMarkerView
android:id="@+id/readMarkerView"
android:layout_width="0dp"
android:layout_height="2dp"
android:background="?attr/vctr_unread_marker_line_color"
android:layout_marginBottom="2dp"
android:visibility="invisible"
android:layout_marginEnd="8dp"
android:layout_marginStart="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:visibility="visible" />
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -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" />
<im.vector.riotx.core.ui.views.ReadMarkerView
android:id="@+id/readMarkerView"
android:layout_width="0dp"
android:layout_height="2dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="2dp"
android:background="?attr/vctr_unread_marker_line_color"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
tools:visibility="visible" />
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -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" />

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/notification_accent_color"
tools:parentTag="android.widget.RelativeLayout">
<TextView
android:id="@+id/jumpToReadMarkerLabelView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_toStartOf="@+id/closeJumpToReadMarkerView"
android:drawableStart="@drawable/arrow_up_circle"
android:drawablePadding="10dp"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:text="@string/room_jump_to_first_unread"
android:textColor="@color/white" />
<ImageView
android:id="@+id/closeJumpToReadMarkerView"
android:layout_width="wrap_content"
android:background="?attr/selectableItemBackground"
android:layout_height="match_parent"
android:layout_alignTop="@+id/jumpToReadMarkerLabelView"
android:layout_alignBottom="@+id/jumpToReadMarkerLabelView"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:contentDescription="@string/action_close"
android:paddingStart="16dp"
android:paddingLeft="16dp"
android:paddingEnd="16dp"
android:paddingRight="16dp"
android:src="@drawable/ic_clear_white" />
</merge>

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:parentTag="android.widget.LinearLayout">
<TextView
android:id="@+id/receiptMore"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="8dp"
android:gravity="center"
android:textSize="12sp"
tools:text="999+" />
<ImageView
android:id="@+id/receiptAvatar5"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/receiptAvatar4"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/receiptAvatar3"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/receiptAvatar2"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/receiptAvatar1"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />
</merge>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="BadgeFloatingActionButton">
<attr name="badgeBackgroundColor" format="color" />
<attr name="badgeTextColor" format="color" />
<attr name="badgeTextPadding" format="dimension" />
<attr name="badgeTextSize" format="dimension" />
</declare-styleable>
</resources>