Compare commits

...

2 Commits

Author SHA1 Message Date
ganfra 51a4c93676 Read markers: continue working on ui 2019-08-23 16:54:32 +02:00
ganfra d8f449388c Read marker: start working on it (no UI) 2019-08-20 18:30:24 +02:00
58 changed files with 1324 additions and 659 deletions

View File

@ -16,7 +16,7 @@ org.gradle.jvmargs=-Xmx1536m




vector.debugPrivateData=false 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 # 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 #vector.debugPrivateData=true

View File

@ -35,9 +35,14 @@ data class RoomSummary(
val highlightCount: Int = 0, val highlightCount: Int = 0,
val tags: List<RoomTag> = emptyList(), val tags: List<RoomTag> = emptyList(),
val membership: Membership = Membership.NONE, val membership: Membership = Membership.NONE,
val versioningState: VersioningState = VersioningState.NONE val versioningState: VersioningState = VersioningState.NONE,
val readMarkerId: String? = null
) { ) {


val isVersioned: Boolean val isVersioned: Boolean
get() = versioningState != VersioningState.NONE get() = versioningState != VersioningState.NONE
}
val hasNewMessages: Boolean
get() = notificationCount != 0
}


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.api.session.room.read

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class FullyReadContent(
@Json(name = "event_id") val eventId: String
)

View File

@ -42,5 +42,10 @@ interface ReadService {


fun isEventRead(eventId: String): Boolean fun isEventRead(eventId: String): Boolean


/**
* Returns a nullable read marker for the room.
*/
fun getReadMarkerLive(): LiveData<String?>

fun getEventReadReceiptsLive(eventId: String): LiveData<List<ReadReceipt>> fun getEventReadReceiptsLive(eventId: String): LiveData<List<ReadReceipt>>
} }

View File

@ -32,6 +32,8 @@ interface Timeline {


var listener: Listener? 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 * This should be called before any other method after creating the timeline. It ensures the underlying database is open
*/ */
@ -42,6 +44,10 @@ interface Timeline {
*/ */
fun dispose() fun dispose()



fun restartWithEventId(eventId: String)


/** /**
* Check if the timeline can be enriched by paginating. * Check if the timeline can be enriched by paginating.
* @param the direction to check in * @param the direction to check in
@ -49,6 +55,7 @@ interface Timeline {
*/ */
fun hasMoreToLoad(direction: Direction): Boolean fun hasMoreToLoad(direction: Direction): Boolean



/** /**
* This is the main method to enrich the timeline with new data. * 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. * It will call the onUpdated method from [Listener] when the data will be processed.
@ -56,9 +63,16 @@ interface Timeline {
*/ */
fun paginate(direction: Direction, count: Int) fun paginate(direction: Direction, count: Int)


fun pendingEventCount() : Int fun pendingEventCount(): Int

fun failedToDeliverEventCount(): Int

fun getIndexOfEvent(eventId: String?): Int?

fun getTimelineEventAtIndex(index: Int): TimelineEvent?

fun getTimelineEventWithId(eventId: String?): TimelineEvent?


fun failedToDeliverEventCount() : Int


interface Listener { interface Listener {
/** /**

View File

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


val metadata = HashMap<String, Any>() val metadata = HashMap<String, Any>()

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.mapper.toEntity
import im.vector.matrix.android.internal.database.model.ChunkEntity 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.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.ReadReceiptEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity
@ -157,7 +158,6 @@ internal fun ChunkEntity.add(roomId: String,
} }
} }



val eventEntity = TimelineEventEntity(localId).also { val eventEntity = TimelineEventEntity(localId).also {
it.root = event.toEntity(roomId).apply { it.root = event.toEntity(roomId).apply {
this.stateIndex = currentStateIndex this.stateIndex = currentStateIndex
@ -169,6 +169,7 @@ internal fun ChunkEntity.add(roomId: String,
it.roomId = roomId it.roomId = roomId
it.annotations = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst() it.annotations = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
it.readReceipts = readReceiptsSummaryEntity it.readReceipts = readReceiptsSummaryEntity
it.readMarker = ReadMarkerEntity.where(realm, roomId = roomId, eventId = eventId).findFirst()
} }
val position = if (direction == PaginationDirection.FORWARDS) 0 else this.timelineEvents.size val position = if (direction == PaginationDirection.FORWARDS) 0 else this.timelineEvents.size
timelineEvents.add(position, eventEntity) timelineEvents.add(position, eventEntity)

View File

@ -26,8 +26,8 @@ import java.util.*
import javax.inject.Inject import javax.inject.Inject


internal class RoomSummaryMapper @Inject constructor( internal class RoomSummaryMapper @Inject constructor(
val cryptoService: CryptoService, private val cryptoService: CryptoService,
val timelineEventMapper: TimelineEventMapper private val timelineEventMapper: TimelineEventMapper
) { ) {


fun map(roomSummaryEntity: RoomSummaryEntity): RoomSummary { fun map(roomSummaryEntity: RoomSummaryEntity): RoomSummary {
@ -43,12 +43,12 @@ internal class RoomSummaryMapper @Inject constructor(
//for now decrypt sync //for now decrypt sync
try { try {
val result = cryptoService.decryptEvent(latestEvent.root, latestEvent.root.roomId + UUID.randomUUID().toString()) val result = cryptoService.decryptEvent(latestEvent.root, latestEvent.root.roomId + UUID.randomUUID().toString())
latestEvent.root.mxDecryptionResult = OlmDecryptionResult( latestEvent.root.mxDecryptionResult = OlmDecryptionResult(
payload = result.clearEvent, payload = result.clearEvent,
senderKey = result.senderCurve25519Key, senderKey = result.senderCurve25519Key,
keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) }, keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) },
forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain
) )
} catch (e: MXCryptoError) { } catch (e: MXCryptoError) {


} }
@ -65,7 +65,8 @@ internal class RoomSummaryMapper @Inject constructor(
notificationCount = roomSummaryEntity.notificationCount, notificationCount = roomSummaryEntity.notificationCount,
tags = tags, tags = tags,
membership = roomSummaryEntity.membership, membership = roomSummaryEntity.membership,
versioningState = roomSummaryEntity.versioningState versioningState = roomSummaryEntity.versioningState,
readMarkerId = roomSummaryEntity.readMarkerId
) )
} }
} }

View File

@ -45,7 +45,8 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS
senderAvatar = timelineEventEntity.senderAvatar, senderAvatar = timelineEventEntity.senderAvatar,
readReceipts = readReceipts?.sortedByDescending { readReceipts = readReceipts?.sortedByDescending {
it.originServerTs it.originServerTs
} ?: emptyList() } ?: emptyList(),
hasReadMarker = timelineEventEntity.readMarker?.eventId?.isEmpty() == false
) )
} }



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,7 +35,8 @@ internal open class RoomSummaryEntity(@PrimaryKey var roomId: String = "",
var otherMemberIds: RealmList<String> = RealmList(), var otherMemberIds: RealmList<String> = RealmList(),
var notificationCount: Int = 0, var notificationCount: Int = 0,
var highlightCount: Int = 0, var highlightCount: Int = 0,
var tags: RealmList<RoomTagEntity> = RealmList() var tags: RealmList<RoomTagEntity> = RealmList(),
var readMarkerId: String? = null
) : RealmObject() { ) : RealmObject() {


private var membershipStr: String = Membership.NONE.name private var membershipStr: String = Membership.NONE.name

View File

@ -43,6 +43,7 @@ import io.realm.annotations.RealmModule
PushConditionEntity::class, PushConditionEntity::class,
PusherEntity::class, PusherEntity::class,
PusherDataEntity::class, PusherDataEntity::class,
ReadReceiptsSummaryEntity::class ReadReceiptsSummaryEntity::class,
ReadMarkerEntity::class
]) ])
internal class SessionRealmModule internal class SessionRealmModule

View File

@ -31,7 +31,8 @@ internal open class TimelineEventEntity(var localId: Long = 0,
var isUniqueDisplayName: Boolean = false, var isUniqueDisplayName: Boolean = false,
var senderAvatar: String? = null, var senderAvatar: String? = null,
var senderMembershipEvent: EventEntity? = null, var senderMembershipEvent: EventEntity? = null,
var readReceipts: ReadReceiptsSummaryEntity? = null var readReceipts: ReadReceiptsSummaryEntity? = null,
var readMarker: ReadMarkerEntity? = null
) : RealmObject() { ) : RealmObject() {


@LinkingObjects("timelineEvents") @LinkingObjects("timelineEvents")

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 im.vector.matrix.android.internal.database.model.ReadReceiptEntityFields
import io.realm.Realm import io.realm.Realm
import io.realm.RealmQuery import io.realm.RealmQuery
import io.realm.RealmResults
import io.realm.kotlin.where import io.realm.kotlin.where


internal fun ReadReceiptEntity.Companion.where(realm: Realm, roomId: String, userId: String): RealmQuery<ReadReceiptEntity> { 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) .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 { internal fun ReadReceiptEntity.Companion.createUnmanaged(roomId: String, eventId: String, userId: String, originServerTs: Double): ReadReceiptEntity {
return ReadReceiptEntity().apply { return ReadReceiptEntity().apply {
this.primaryKey = "${roomId}_$userId" this.primaryKey = "${roomId}_$userId"

View File

@ -31,6 +31,12 @@ internal fun RoomSummaryEntity.Companion.where(realm: Realm, roomId: String? = n
return query 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> { internal fun RoomSummaryEntity.Companion.getDirectRooms(realm: Realm): RealmResults<RoomSummaryEntity> {
return RoomSummaryEntity.where(realm) return RoomSummaryEntity.where(realm)
.equalTo(RoomSummaryEntityFields.IS_DIRECT, true) .equalTo(RoomSummaryEntityFields.IS_DIRECT, true)

View File

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

View File

@ -36,9 +36,7 @@ import im.vector.matrix.android.internal.di.Unauthenticated
import im.vector.matrix.android.internal.network.AccessTokenInterceptor import im.vector.matrix.android.internal.network.AccessTokenInterceptor
import im.vector.matrix.android.internal.network.RetrofitFactory import im.vector.matrix.android.internal.network.RetrofitFactory
import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater
import im.vector.matrix.android.internal.session.room.DefaultRoomFactory
import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater
import im.vector.matrix.android.internal.session.room.RoomFactory
import im.vector.matrix.android.internal.session.room.create.RoomCreateEventLiveObserver import im.vector.matrix.android.internal.session.room.create.RoomCreateEventLiveObserver
import im.vector.matrix.android.internal.session.room.prune.EventsPruner import im.vector.matrix.android.internal.session.room.prune.EventsPruner
import im.vector.matrix.android.internal.session.room.tombstone.RoomTombstoneEventLiveObserver import im.vector.matrix.android.internal.session.room.tombstone.RoomTombstoneEventLiveObserver

View File

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

View File

@ -28,8 +28,10 @@ import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.internal.database.RealmLiveData import im.vector.matrix.android.internal.database.RealmLiveData
import im.vector.matrix.android.internal.database.mapper.ReadReceiptsSummaryMapper import im.vector.matrix.android.internal.database.mapper.ReadReceiptsSummaryMapper
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.query.find import im.vector.matrix.android.internal.database.query.find
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom 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.where
@ -93,6 +95,15 @@ internal class DefaultReadService @AssistedInject constructor(@Assisted private
return isEventRead return isEventRead
} }


override fun getReadMarkerLive(): LiveData<String?> {
val liveRealmData = RealmLiveData(monarchy.realmConfiguration) { realm ->
ReadMarkerEntity.where(realm, roomId)
}
return Transformations.map(liveRealmData) { results ->
results.firstOrNull()?.eventId
}
}

override fun getEventReadReceiptsLive(eventId: String): LiveData<List<ReadReceipt>> { override fun getEventReadReceiptsLive(eventId: String): LiveData<List<ReadReceipt>> {
val liveEntity = RealmLiveData(monarchy.realmConfiguration) { realm -> val liveEntity = RealmLiveData(monarchy.realmConfiguration) { realm ->
ReadReceiptsSummaryEntity.where(realm, eventId) ReadReceiptsSummaryEntity.where(realm, eventId)

View File

@ -18,19 +18,25 @@ package im.vector.matrix.android.internal.session.room.read


import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.room.read.FullyReadContent
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity 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.TimelineEventEntity
import im.vector.matrix.android.internal.database.query.find 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.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.getOrCreate
import im.vector.matrix.android.internal.database.query.latestEvent import im.vector.matrix.android.internal.database.query.latestEvent
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.network.executeRequest 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.RoomAPI
import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory
import im.vector.matrix.android.internal.session.sync.RoomFullyReadHandler
import im.vector.matrix.android.internal.task.Task import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.awaitTransaction
import io.realm.Realm import io.realm.Realm
import io.realm.RealmConfiguration
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject


@ -49,7 +55,8 @@ private const val READ_RECEIPT = "m.read"


internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI: RoomAPI, internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI: RoomAPI,
private val credentials: Credentials, private val credentials: Credentials,
private val monarchy: Monarchy private val monarchy: Monarchy,
private val roomFullyReadHandler: RoomFullyReadHandler
) : SetReadMarkersTask { ) : SetReadMarkersTask {


override suspend fun execute(params: SetReadMarkersTask.Params) { override suspend fun execute(params: SetReadMarkersTask.Params) {
@ -57,6 +64,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI
val fullyReadEventId: String? val fullyReadEventId: String?
val readReceiptEventId: String? val readReceiptEventId: String?


Timber.v("Execute set read marker with params: $params")
if (params.markAllAsRead) { if (params.markAllAsRead) {
val latestSyncedEventId = Realm.getInstance(monarchy.realmConfiguration).use { realm -> val latestSyncedEventId = Realm.getInstance(monarchy.realmConfiguration).use { realm ->
TimelineEventEntity.latestEvent(realm, roomId = params.roomId, includesSending = false)?.eventId TimelineEventEntity.latestEvent(realm, roomId = params.roomId, includesSending = false)?.eventId
@ -68,16 +76,16 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI
readReceiptEventId = params.readReceiptEventId readReceiptEventId = params.readReceiptEventId
} }


if (fullyReadEventId != null) { if (fullyReadEventId != null && isReadMarkerMoreRecent(params.roomId, fullyReadEventId)) {
if (LocalEchoEventFactory.isLocalEchoId(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 ${params.fullyReadEventId}")
} else { } else {
updateReadMarker(params.roomId, fullyReadEventId)
markers[READ_MARKER] = fullyReadEventId markers[READ_MARKER] = fullyReadEventId
} }
} }
if (readReceiptEventId != null if (readReceiptEventId != null
&& !isEventRead(params.roomId, readReceiptEventId)) { && !isEventRead(params.roomId, readReceiptEventId)) {

if (LocalEchoEventFactory.isLocalEchoId(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 ${params.fullyReadEventId}")
} else { } else {
@ -93,12 +101,30 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI
} }
} }


private fun updateNotificationCountIfNecessary(roomId: String, eventId: String) {
monarchy.writeAsync { realm -> private fun isReadMarkerMoreRecent(roomId: String, fullyReadEventId: String): Boolean {
return Realm.getInstance(monarchy.realmConfiguration).use { realm ->
val readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId).findFirst()
val readMarkerEvent = readMarkerEntity?.timelineEvent?.firstOrNull()
val eventToCheck = TimelineEventEntity.where(realm, eventId = fullyReadEventId).findFirst()
val readReceiptIndex = readMarkerEvent?.root?.displayIndex ?: Int.MAX_VALUE
val eventToCheckIndex = eventToCheck?.root?.displayIndex ?: Int.MIN_VALUE
eventToCheckIndex > readReceiptIndex
}
}

private suspend fun updateReadMarker(roomId: String, eventId: String) {
monarchy.awaitTransaction { realm ->
roomFullyReadHandler.handle(realm, roomId, FullyReadContent(eventId))
}
}

private suspend fun updateNotificationCountIfNecessary(roomId: String, eventId: String) {
monarchy.awaitTransaction { realm ->
val isLatestReceived = TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId == eventId val isLatestReceived = TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId == eventId
if (isLatestReceived) { if (isLatestReceived) {
val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst() val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst()
?: return@writeAsync ?: return@awaitTransaction
roomSummary.notificationCount = 0 roomSummary.notificationCount = 0
roomSummary.highlightCount = 0 roomSummary.highlightCount = 0
} }
@ -106,19 +132,17 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI
} }


private fun isEventRead(roomId: String, eventId: String): Boolean { private fun isEventRead(roomId: String, eventId: String): Boolean {
var isEventRead = false return Realm.getInstance(monarchy.realmConfiguration).use { realm ->
monarchy.doWithRealm { val readReceipt = ReadReceiptEntity.where(realm, roomId, credentials.userId).findFirst()
val readReceipt = ReadReceiptEntity.where(it, roomId, credentials.userId).findFirst() ?: return false
?: return@doWithRealm val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId)
val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(it, roomId) ?: return false
?: return@doWithRealm
val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex
?: Int.MIN_VALUE ?: Int.MIN_VALUE
val eventToCheckIndex = liveChunk.timelineEvents.find(eventId)?.root?.displayIndex val eventToCheckIndex = liveChunk.timelineEvents.find(eventId)?.root?.displayIndex
?: Int.MAX_VALUE ?: Int.MAX_VALUE
isEventRead = eventToCheckIndex <= readReceiptIndex eventToCheckIndex <= readReceiptIndex
} }
return isEventRead
} }


} }

View File

@ -27,33 +27,15 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
import im.vector.matrix.android.api.util.CancelableBag import im.vector.matrix.android.api.util.CancelableBag
import im.vector.matrix.android.internal.database.mapper.TimelineEventMapper 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.mapper.asDomain
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.*
import im.vector.matrix.android.internal.database.model.ChunkEntityFields import im.vector.matrix.android.internal.database.query.*
import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.EventEntityFields
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields
import im.vector.matrix.android.internal.database.query.FilterContent
import im.vector.matrix.android.internal.database.query.findAllInRoomWithSendStates
import im.vector.matrix.android.internal.database.query.findIncludingEvent
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.database.query.whereInRoom
import im.vector.matrix.android.internal.task.TaskConstraints import im.vector.matrix.android.internal.task.TaskConstraints
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.Debouncer import im.vector.matrix.android.internal.util.Debouncer
import im.vector.matrix.android.internal.util.createBackgroundHandler import im.vector.matrix.android.internal.util.createBackgroundHandler
import im.vector.matrix.android.internal.util.createUIHandler import im.vector.matrix.android.internal.util.createUIHandler
import io.realm.OrderedCollectionChangeSet import io.realm.*
import io.realm.OrderedRealmCollectionChangeListener
import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.RealmQuery
import io.realm.RealmResults
import io.realm.Sort
import timber.log.Timber import timber.log.Timber
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -67,7 +49,7 @@ private const val DISPLAY_INDEX_UNKNOWN = Int.MIN_VALUE


internal class DefaultTimeline( internal class DefaultTimeline(
private val roomId: String, private val roomId: String,
private val initialEventId: String? = null, private var initialEventId: String? = null,
private val realmConfiguration: RealmConfiguration, private val realmConfiguration: RealmConfiguration,
private val taskExecutor: TaskExecutor, private val taskExecutor: TaskExecutor,
private val contextOfEventTask: GetContextOfEventTask, private val contextOfEventTask: GetContextOfEventTask,
@ -75,8 +57,9 @@ internal class DefaultTimeline(
private val cryptoService: CryptoService, private val cryptoService: CryptoService,
private val timelineEventMapper: TimelineEventMapper, private val timelineEventMapper: TimelineEventMapper,
private val settings: TimelineSettings, private val settings: TimelineSettings,
private val hiddenReadReceipts: TimelineHiddenReadReceipts private val hiddenReadReceipts: TimelineHiddenReadReceipts,
) : Timeline, TimelineHiddenReadReceipts.Delegate { private val hiddenReadMarker: TimelineHiddenReadMarker
) : Timeline, TimelineHiddenReadReceipts.Delegate, TimelineHiddenReadMarker.Delegate {


private companion object { private companion object {
val BACKGROUND_HANDLER = createBackgroundHandler("TIMELINE_DB_THREAD") val BACKGROUND_HANDLER = createBackgroundHandler("TIMELINE_DB_THREAD")
@ -104,7 +87,6 @@ internal class DefaultTimeline(


private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN
private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN
private val isLive = initialEventId == null
private val builtEvents = Collections.synchronizedList<TimelineEvent>(ArrayList()) private val builtEvents = Collections.synchronizedList<TimelineEvent>(ArrayList())
private val builtEventsIdMap = Collections.synchronizedMap(HashMap<String, Int>()) private val builtEventsIdMap = Collections.synchronizedMap(HashMap<String, Int>())
private val backwardsPaginationState = AtomicReference(PaginationState()) private val backwardsPaginationState = AtomicReference(PaginationState())
@ -112,6 +94,9 @@ internal class DefaultTimeline(


private val timelineID = UUID.randomUUID().toString() private val timelineID = UUID.randomUUID().toString()


override val isLive
get() = initialEventId == null

private val eventDecryptor = TimelineEventDecryptor(realmConfiguration, timelineID, cryptoService) private val eventDecryptor = TimelineEventDecryptor(realmConfiguration, timelineID, cryptoService)


private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<TimelineEventEntity>> { results, changeSet -> private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<TimelineEventEntity>> { results, changeSet ->
@ -120,10 +105,7 @@ internal class DefaultTimeline(
} else { } else {
// If changeSet has deletion we are having a gap, so we clear everything // If changeSet has deletion we are having a gap, so we clear everything
if (changeSet.deletionRanges.isNotEmpty()) { if (changeSet.deletionRanges.isNotEmpty()) {
prevDisplayIndex = DISPLAY_INDEX_UNKNOWN clearAllValues()
nextDisplayIndex = DISPLAY_INDEX_UNKNOWN
builtEvents.clear()
builtEventsIdMap.clear()
} }
changeSet.insertionRanges.forEach { range -> changeSet.insertionRanges.forEach { range ->
val (startDisplayIndex, direction) = if (range.startIndex == 0) { val (startDisplayIndex, direction) = if (range.startIndex == 0) {
@ -149,13 +131,9 @@ internal class DefaultTimeline(
changeSet.changes.forEach { index -> changeSet.changes.forEach { index ->
val eventEntity = results[index] val eventEntity = results[index]
eventEntity?.eventId?.let { eventId -> eventEntity?.eventId?.let { eventId ->
builtEventsIdMap[eventId]?.let { builtIndex -> hasChanged = rebuildEvent(eventId) {
//Update an existing event buildTimelineEvent(eventEntity)
builtEvents[builtIndex]?.let { te -> } || hasChanged
builtEvents[builtIndex] = buildTimelineEvent(eventEntity)
hasChanged = true
}
}
} }
} }
if (hasChanged) postSnapshot() if (hasChanged) postSnapshot()
@ -163,27 +141,21 @@ internal class DefaultTimeline(
} }


private val relationsListener = OrderedRealmCollectionChangeListener<RealmResults<EventAnnotationsSummaryEntity>> { collection, changeSet -> private val relationsListener = OrderedRealmCollectionChangeListener<RealmResults<EventAnnotationsSummaryEntity>> { collection, changeSet ->

var hasChange = false var hasChange = false


(changeSet.insertions + changeSet.changes).forEach { (changeSet.insertions + changeSet.changes).forEach {
val eventRelations = collection[it] val eventRelations = collection[it]
if (eventRelations != null) { if (eventRelations != null) {
builtEventsIdMap[eventRelations.eventId]?.let { builtIndex -> hasChange = rebuildEvent(eventRelations.eventId) { te ->
//Update the relation of existing event te.copy(annotations = eventRelations.asDomain())
builtEvents[builtIndex]?.let { te -> } || hasChange
builtEvents[builtIndex] = te.copy(annotations = eventRelations.asDomain())
hasChange = true
}
}
} }
} }
if (hasChange) if (hasChange) postSnapshot()
postSnapshot()
} }




// Public methods ****************************************************************************** // Public methods ******************************************************************************


override fun paginate(direction: Timeline.Direction, count: Int) { override fun paginate(direction: Timeline.Direction, count: Int) {
BACKGROUND_HANDLER.post { BACKGROUND_HANDLER.post {
@ -240,7 +212,7 @@ internal class DefaultTimeline(
if (settings.buildReadReceipts) { if (settings.buildReadReceipts) {
hiddenReadReceipts.start(realm, liveEvents, this) hiddenReadReceipts.start(realm, liveEvents, this)
} }

hiddenReadMarker.start(realm, liveEvents, this)
isReady.set(true) isReady.set(true)
} }
} }
@ -255,9 +227,11 @@ internal class DefaultTimeline(
roomEntity?.sendingTimelineEvents?.removeAllChangeListeners() roomEntity?.sendingTimelineEvents?.removeAllChangeListeners()
eventRelations.removeAllChangeListeners() eventRelations.removeAllChangeListeners()
liveEvents.removeAllChangeListeners() liveEvents.removeAllChangeListeners()
hiddenReadMarker.dispose()
if (settings.buildReadReceipts) { if (settings.buildReadReceipts) {
hiddenReadReceipts.dispose() hiddenReadReceipts.dispose()
} }
clearAllValues()
backgroundRealm.getAndSet(null).also { backgroundRealm.getAndSet(null).also {
it.close() it.close()
} }
@ -265,6 +239,27 @@ internal class DefaultTimeline(
} }
} }


override fun restartWithEventId(eventId: String) {
dispose()
initialEventId = eventId
start()
postSnapshot()
}

override fun getIndexOfEvent(eventId: String?): Int? {
return builtEventsIdMap[eventId]
}

override fun getTimelineEventAtIndex(index: Int): TimelineEvent? {
return builtEvents.getOrNull(index)
}

override fun getTimelineEventWithId(eventId: String?): TimelineEvent? {
return builtEventsIdMap[eventId]?.let {
getTimelineEventAtIndex(it)
}
}

override fun hasMoreToLoad(direction: Timeline.Direction): Boolean { override fun hasMoreToLoad(direction: Timeline.Direction): Boolean {
return hasMoreInCache(direction) || !hasReachedEnd(direction) return hasMoreInCache(direction) || !hasReachedEnd(direction)
} }
@ -272,20 +267,38 @@ internal class DefaultTimeline(
// TimelineHiddenReadReceipts.Delegate // TimelineHiddenReadReceipts.Delegate


override fun rebuildEvent(eventId: String, readReceipts: List<ReadReceipt>): Boolean { override fun rebuildEvent(eventId: String, readReceipts: List<ReadReceipt>): Boolean {
return builtEventsIdMap[eventId]?.let { builtIndex -> return rebuildEvent(eventId) { te ->
//Update the relation of existing event te.copy(readReceipts = readReceipts)
builtEvents[builtIndex]?.let { te -> }
builtEvents[builtIndex] = te.copy(readReceipts = readReceipts)
true
}
} ?: false
} }


override fun onReadReceiptsUpdated() { override fun onReadReceiptsUpdated() {
postSnapshot() postSnapshot()
} }


// Private methods ***************************************************************************** // TimelineHiddenReadMarker.Delegate

override fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean {
return rebuildEvent(eventId) { te ->
te.copy(hasReadMarker = hasReadMarker)
}
}

override fun onReadMarkerUpdated() {
postSnapshot()
}

// Private methods *****************************************************************************

private fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent): Boolean {
return builtEventsIdMap[eventId]?.let { builtIndex ->
//Update the relation of existing event
builtEvents[builtIndex]?.let { te ->
builtEvents[builtIndex] = builder(te)
true
}
} ?: false
}


private fun hasMoreInCache(direction: Timeline.Direction): Boolean { private fun hasMoreInCache(direction: Timeline.Direction): Boolean {
return Realm.getInstance(realmConfiguration).use { localRealm -> return Realm.getInstance(realmConfiguration).use { localRealm ->
@ -395,8 +408,9 @@ internal class DefaultTimeline(


prevDisplayIndex = initialDisplayIndex prevDisplayIndex = initialDisplayIndex
nextDisplayIndex = initialDisplayIndex nextDisplayIndex = initialDisplayIndex
if (initialEventId != null && shouldFetchInitialEvent) { val currentInitialEventId = initialEventId
fetchEvent(initialEventId) if (currentInitialEventId != null && shouldFetchInitialEvent) {
fetchEvent(currentInitialEventId)
} else { } else {
val count = Math.min(settings.initialSize, liveEvents.size) val count = Math.min(settings.initialSize, liveEvents.size)
if (isLive) { if (isLive) {
@ -543,10 +557,11 @@ internal class DefaultTimeline(
} }


private fun findCurrentChunk(realm: Realm): ChunkEntity? { private fun findCurrentChunk(realm: Realm): ChunkEntity? {
return if (initialEventId == null) { val currentInitialEventId = initialEventId
return if (currentInitialEventId == null) {
ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) ChunkEntity.findLastLiveChunkFromRoom(realm, roomId)
} else { } else {
ChunkEntity.findIncludingEvent(realm, initialEventId) ChunkEntity.findIncludingEvent(realm, currentInitialEventId)
} }
} }


@ -566,12 +581,24 @@ internal class DefaultTimeline(
} }


private fun postSnapshot() { private fun postSnapshot() {
val snapshot = createSnapshot() BACKGROUND_HANDLER.post {
val runnable = Runnable { listener?.onUpdated(snapshot) } val snapshot = createSnapshot()
debouncer.debounce("post_snapshot", runnable, 50) val runnable = Runnable { listener?.onUpdated(snapshot) }
debouncer.debounce("post_snapshot", runnable, 50)
}
} }


// Extension methods *************************************************************************** private fun clearAllValues() {
prevDisplayIndex = DISPLAY_INDEX_UNKNOWN
nextDisplayIndex = DISPLAY_INDEX_UNKNOWN
builtEvents.clear()
builtEventsIdMap.clear()
backwardsPaginationState.set(PaginationState())
forwardsPaginationState.set(PaginationState())
}


// Extension methods ***************************************************************************


private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { private fun Timeline.Direction.toPaginationDirection(): PaginationDirection {
return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS

View File

@ -59,7 +59,8 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv
cryptoService, cryptoService,
timelineEventMapper, timelineEventMapper,
settings, settings,
TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings) TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings),
TimelineHiddenReadMarker(roomId)
) )
} }



View File

@ -0,0 +1,96 @@
/*

* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.

*/

package im.vector.matrix.android.internal.session.room.timeline

import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields
import im.vector.matrix.android.internal.database.query.where
import io.realm.Realm
import io.realm.RealmObjectChangeListener
import io.realm.RealmResults

/**
* This class is responsible for handling the read marker for hidden events.
* When an hidden event has read marker, we want to transfer it on the first older displayed event.
* It has to be used in [DefaultTimeline] and we should call the [start] and [dispose] methods to properly handle realm subscription.
*/
internal class TimelineHiddenReadMarker constructor(private val roomId: String) {

interface Delegate {
fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean
fun onReadMarkerUpdated()
}

private var previousDisplayedEventId: String? = null
private var readMarkerEntity: ReadMarkerEntity? = null

private lateinit var liveEvents: RealmResults<TimelineEventEntity>
private lateinit var delegate: Delegate

private val readMarkerListener = RealmObjectChangeListener<ReadMarkerEntity> { readMarker, _ ->
var hasChange = false
previousDisplayedEventId?.also {
hasChange = delegate.rebuildEvent(it, false)
previousDisplayedEventId = null
}
val isEventHidden = liveEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, readMarker.eventId).findFirst() == null
if (isEventHidden) {
val hiddenEvent = readMarker.timelineEvent?.firstOrNull()
?: return@RealmObjectChangeListener
val displayIndex = hiddenEvent.root?.displayIndex
if (displayIndex != null) {
// Then we are looking for the first displayable event after the hidden one
val firstDisplayedEvent = liveEvents.where()
.lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex)
.findFirst()

// If we find one, we should rebuild this one with marker
if (firstDisplayedEvent != null) {
previousDisplayedEventId = firstDisplayedEvent.eventId
hasChange = delegate.rebuildEvent(firstDisplayedEvent.eventId, true)
}
}
}
if (hasChange) delegate.onReadMarkerUpdated()
}


/**
* Start the realm query subscription. Has to be called on an HandlerThread
*/
fun start(realm: Realm, liveEvents: RealmResults<TimelineEventEntity>, delegate: Delegate) {
this.liveEvents = liveEvents
this.delegate = delegate
// We are looking for read receipts set on hidden events.
// We only accept those with a timelineEvent (so coming from pagination/sync).
readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId)
.findFirstAsync()
.also { it.addChangeListener(readMarkerListener) }

}

/**
* Dispose the realm query subscription. Has to be called on an HandlerThread
*/
fun dispose() {
this.readMarkerEntity?.removeAllChangeListeners()
}

}

View File

@ -0,0 +1,51 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.matrix.android.internal.session.sync

import im.vector.matrix.android.api.session.room.read.FullyReadContent
import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.query.getOrCreate
import im.vector.matrix.android.internal.database.query.where
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
}
val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId).apply {
eventId = content.eventId
}

// Remove the old marker if any
readMarkerEntity.timelineEvent?.firstOrNull()?.readMarker = null
// Attach to timelineEvent if known
val timelineEventEntity = TimelineEventEntity.where(realm, eventId = content.eventId).findFirst()
timelineEventEntity?.readMarker = readMarkerEntity
}

}

View File

@ -23,8 +23,13 @@ import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.tag.RoomTagContent import im.vector.matrix.android.api.session.room.model.tag.RoomTagContent
import im.vector.matrix.android.api.session.room.read.FullyReadContent
import im.vector.matrix.android.internal.crypto.CryptoManager import im.vector.matrix.android.internal.crypto.CryptoManager
import im.vector.matrix.android.internal.database.helper.* import im.vector.matrix.android.internal.database.helper.add
import im.vector.matrix.android.internal.database.helper.addOrUpdate
import im.vector.matrix.android.internal.database.helper.addStateEvent
import im.vector.matrix.android.internal.database.helper.lastStateIndex
import im.vector.matrix.android.internal.database.helper.updateSenderDataFor
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.EventEntityFields import im.vector.matrix.android.internal.database.model.EventEntityFields
import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomEntity
@ -37,7 +42,11 @@ import im.vector.matrix.android.internal.session.notification.DefaultPushRuleSer
import im.vector.matrix.android.internal.session.notification.ProcessEventForPushTask import im.vector.matrix.android.internal.session.notification.ProcessEventForPushTask
import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater 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.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.session.user.UserEntityFactory
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.task.configureWith
@ -50,6 +59,7 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
private val readReceiptHandler: ReadReceiptHandler, private val readReceiptHandler: ReadReceiptHandler,
private val roomSummaryUpdater: RoomSummaryUpdater, private val roomSummaryUpdater: RoomSummaryUpdater,
private val roomTagHandler: RoomTagHandler, private val roomTagHandler: RoomTagHandler,
private val roomFullyReadHandler: RoomFullyReadHandler,
private val cryptoManager: CryptoManager, private val cryptoManager: CryptoManager,
private val tokenStore: SyncTokenStore, private val tokenStore: SyncTokenStore,
private val pushRuleService: DefaultPushRuleService, private val pushRuleService: DefaultPushRuleService,
@ -247,11 +257,16 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
} }


private fun handleRoomAccountDataEvents(realm: Realm, roomId: String, accountData: RoomSyncAccountData) { private fun handleRoomAccountDataEvents(realm: Realm, roomId: String, accountData: RoomSyncAccountData) {
accountData.events for (event in accountData.events) {
.asSequence() val eventType = event.getClearType()
.filter { it.getClearType() == EventType.TAG } if (eventType == EventType.TAG) {
.map { it.content.toModel<RoomTagContent>() } val content = event.getClearContent().toModel<RoomTagContent>()
.forEach { roomTagHandler.handle(realm, roomId, it) } roomTagHandler.handle(realm, roomId, content)
} else if (eventType == EventType.FULLY_READ) {
val content = event.getClearContent().toModel<FullyReadContent>()
roomFullyReadHandler.handle(realm, roomId, content)
}
}
} }


} }

View File

@ -0,0 +1,75 @@
/*

* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.

*/

package im.vector.riotx.core.ui.views

import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import android.widget.RelativeLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import butterknife.ButterKnife
import im.vector.riotx.R
import im.vector.riotx.features.themes.ThemeUtils
import kotlinx.android.synthetic.main.view_jump_to_read_marker.view.*
import me.gujun.android.span.span
import me.saket.bettermovementmethod.BetterLinkMovementMethod

class JumpToReadMarkerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RelativeLayout(context, attrs, defStyleAttr) {

interface Callback {
fun onJumpToReadMarkerClicked(readMarkerId: String)
fun onClearReadMarkerClicked()
}

var callback: Callback? = null

init {
setupView()
}

private fun setupView() {
LinearLayout.inflate(context, R.layout.view_jump_to_read_marker, this)
setBackgroundColor(ContextCompat.getColor(context, R.color.notification_accent_color))
jumpToReadMarkerLabelView.movementMethod = BetterLinkMovementMethod.getInstance()
isClickable = true
closeJumpToReadMarkerView.setOnClickListener {
visibility = View.GONE
callback?.onClearReadMarkerClicked()
}
}

fun render(show: Boolean, readMarkerId: String?) {
isVisible = show
if (readMarkerId != null) {
jumpToReadMarkerLabelView.text = span(resources.getString(R.string.room_jump_to_first_unread)) {
textDecorationLine = "underline"
onClick = { callback?.onJumpToReadMarkerClicked(readMarkerId) }
}
}

}


}

View File

@ -0,0 +1,86 @@
/*

* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.

*/

package im.vector.riotx.core.ui.views

import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import im.vector.riotx.R
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import kotlinx.coroutines.*

private const val DELAY_IN_MS = 1_500L

class ReadMarkerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

interface Callback {
fun onReadMarkerDisplayed()
}

private var callback: Callback? = null
private var callbackDispatcherJob: Job? = null

fun bindView(informationData: MessageInformationData, readMarkerCallback: Callback) {
this.callback = readMarkerCallback
if (informationData.displayReadMarker) {
visibility = VISIBLE
callbackDispatcherJob = GlobalScope.launch(Dispatchers.Main) {
delay(DELAY_IN_MS)
callback?.onReadMarkerDisplayed()
}
startAnimation()
} else {
visibility = INVISIBLE
}

}

fun unbind() {
this.callbackDispatcherJob?.cancel()
this.callback = null
this.animation?.cancel()
this.visibility = INVISIBLE
}

private fun startAnimation() {
if (animation == null) {
animation = AnimationUtils.loadAnimation(context, R.anim.unread_marker_anim)
animation.startOffset = DELAY_IN_MS / 2
animation.duration = DELAY_IN_MS / 2
animation.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
}

override fun onAnimationEnd(animation: Animation) {
visibility = INVISIBLE
}

override fun onAnimationRepeat(animation: Animation) {}
})
}
animation.start()
}

}

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

@ -18,7 +18,6 @@ package im.vector.riotx.features.home.room.detail


import com.jaiselrahman.filepicker.model.MediaFile import com.jaiselrahman.filepicker.model.MediaFile
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.timeline.Timeline 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.TimelineEvent
@ -27,15 +26,18 @@ sealed class RoomDetailActions {


data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailActions() data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailActions()
data class SendMedia(val mediaFiles: List<MediaFile>) : 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 LoadMoreTimelineEvents(val direction: Timeline.Direction) : RoomDetailActions()
data class SendReaction(val reaction: String, val targetEventId: String) : RoomDetailActions() data class SendReaction(val reaction: String, val targetEventId: String) : RoomDetailActions()
data class RedactAction(val targetEventId: String, val reason: 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 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 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 DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailActions()
data class HandleTombstoneEvent(val event: Event): RoomDetailActions() data class HandleTombstoneEvent(val event: Event) : RoomDetailActions()
object AcceptInvite : RoomDetailActions() object AcceptInvite : RoomDetailActions()
object RejectInvite : RoomDetailActions() object RejectInvite : RoomDetailActions()


@ -47,5 +49,4 @@ sealed class RoomDetailActions {
object ClearSendQueue : RoomDetailActions() object ClearSendQueue : RoomDetailActions()
object ResendAll : RoomDetailActions() object ResendAll : RoomDetailActions()



} }

View File

@ -28,7 +28,12 @@ import android.os.Parcelable
import android.text.Editable import android.text.Editable
import android.text.Spannable import android.text.Spannable
import android.text.TextUtils import android.text.TextUtils
import android.view.* import android.view.HapticFeedbackConstants
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.Window
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
@ -46,7 +51,12 @@ import androidx.recyclerview.widget.RecyclerView
import butterknife.BindView import butterknife.BindView
import com.airbnb.epoxy.EpoxyModel import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.EpoxyVisibilityTracker import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.* import com.airbnb.mvrx.Async
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.github.piasy.biv.BigImageViewer import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.ImageLoader import com.github.piasy.biv.loader.ImageLoader
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
@ -60,7 +70,13 @@ import im.vector.matrix.android.api.permalinks.PermalinkFactory
import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.Event 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.Membership
import im.vector.matrix.android.api.session.room.model.message.* import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
@ -77,9 +93,21 @@ import im.vector.riotx.core.extensions.observeEvent
import im.vector.riotx.core.extensions.setTextOrHide import im.vector.riotx.core.extensions.setTextOrHide
import im.vector.riotx.core.files.addEntryToDownloadManager import im.vector.riotx.core.files.addEntryToDownloadManager
import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.ui.views.NotificationAreaView
import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.utils.* import im.vector.riotx.core.ui.views.JumpToReadMarkerView
import im.vector.riotx.core.ui.views.NotificationAreaView
import im.vector.riotx.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_DOWNLOAD_FILE
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA
import im.vector.riotx.core.utils.allGranted
import im.vector.riotx.core.utils.checkPermissions
import im.vector.riotx.core.utils.copyToClipboard
import im.vector.riotx.core.utils.openCamera
import im.vector.riotx.core.utils.shareMedia
import im.vector.riotx.core.utils.toast
import im.vector.riotx.features.autocomplete.command.AutocompleteCommandPresenter import im.vector.riotx.features.autocomplete.command.AutocompleteCommandPresenter
import im.vector.riotx.features.autocomplete.command.CommandAutocompletePolicy import im.vector.riotx.features.autocomplete.command.CommandAutocompletePolicy
import im.vector.riotx.features.autocomplete.user.AutocompleteUserPresenter import im.vector.riotx.features.autocomplete.user.AutocompleteUserPresenter
@ -94,9 +122,18 @@ import im.vector.riotx.features.home.room.detail.composer.TextComposerViewModel
import im.vector.riotx.features.home.room.detail.composer.TextComposerViewState import im.vector.riotx.features.home.room.detail.composer.TextComposerViewState
import im.vector.riotx.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet 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.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.action.* import im.vector.riotx.features.home.room.detail.timeline.action.ActionsHandler
import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsBottomSheet
import im.vector.riotx.features.home.room.detail.timeline.action.SimpleAction
import im.vector.riotx.features.home.room.detail.timeline.action.ViewEditHistoryBottomSheet
import im.vector.riotx.features.home.room.detail.timeline.action.ViewReactionBottomSheet
import im.vector.riotx.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener import im.vector.riotx.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem
import im.vector.riotx.features.home.room.detail.timeline.item.MessageImageVideoItem
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem
import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData
import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.html.EventHtmlRenderer
import im.vector.riotx.features.html.PillImageSpan import im.vector.riotx.features.html.PillImageSpan
import im.vector.riotx.features.invite.VectorInviteView import im.vector.riotx.features.invite.VectorInviteView
@ -134,7 +171,8 @@ class RoomDetailFragment :
VectorBaseFragment(), VectorBaseFragment(),
TimelineEventController.Callback, TimelineEventController.Callback,
AutocompleteUserPresenter.Callback, AutocompleteUserPresenter.Callback,
VectorInviteView.Callback { VectorInviteView.Callback,
JumpToReadMarkerView.Callback {


companion object { companion object {


@ -194,6 +232,7 @@ class RoomDetailFragment :
override fun getMenuRes() = R.menu.menu_timeline override fun getMenuRes() = R.menu.menu_timeline


private lateinit var actionViewModel: ActionsHandler private lateinit var actionViewModel: ActionsHandler
private lateinit var layoutManager: LinearLayoutManager


@BindView(R.id.composerLayout) @BindView(R.id.composerLayout)
lateinit var composerLayout: TextComposerView lateinit var composerLayout: TextComposerView
@ -211,6 +250,7 @@ class RoomDetailFragment :
setupAttachmentButton() setupAttachmentButton()
setupInviteView() setupInviteView()
setupNotificationView() setupNotificationView()
setupJumpToReadMarkerView()
roomDetailViewModel.subscribe { renderState(it) } roomDetailViewModel.subscribe { renderState(it) }
textComposerViewModel.subscribe { renderTextComposerState(it) } textComposerViewModel.subscribe { renderTextComposerState(it) }
roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) } roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) }
@ -224,8 +264,12 @@ class RoomDetailFragment :
} }


roomDetailViewModel.navigateToEvent.observeEvent(this) { roomDetailViewModel.navigateToEvent.observeEvent(this) {
// val scrollPosition = timelineEventController.searchPositionOfEvent(it)
scrollOnHighlightedEventCallback.scheduleScrollTo(it) if (scrollPosition == null) {
scrollOnHighlightedEventCallback.scheduleScrollTo(it)
} else {
layoutManager.scrollToPosition(scrollPosition)
}
} }


roomDetailViewModel.selectSubscribe(this, RoomDetailViewState::tombstoneEventHandling, uniqueOnly("tombstoneEventHandling")) { roomDetailViewModel.selectSubscribe(this, RoomDetailViewState::tombstoneEventHandling, uniqueOnly("tombstoneEventHandling")) {
@ -259,6 +303,10 @@ class RoomDetailFragment :
} }
} }


private fun setupJumpToReadMarkerView() {
jumpToReadMarkerView.callback = this
}

private fun setupNotificationView() { private fun setupNotificationView() {
notificationAreaView.delegate = object : NotificationAreaView.Delegate { notificationAreaView.delegate = object : NotificationAreaView.Delegate {


@ -380,7 +428,7 @@ class RoomDetailFragment :
private fun setupRecyclerView() { private fun setupRecyclerView() {
val epoxyVisibilityTracker = EpoxyVisibilityTracker() val epoxyVisibilityTracker = EpoxyVisibilityTracker()
epoxyVisibilityTracker.attach(recyclerView) epoxyVisibilityTracker.attach(recyclerView)
val layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true) layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, true)
val stateRestorer = LayoutManagerStateRestorer(layoutManager).register() val stateRestorer = LayoutManagerStateRestorer(layoutManager).register()
scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager) scrollOnNewMessageCallback = ScrollOnNewMessageCallback(layoutManager)
scrollOnHighlightedEventCallback = ScrollOnHighlightedEventCallback(layoutManager, timelineEventController) scrollOnHighlightedEventCallback = ScrollOnHighlightedEventCallback(layoutManager, timelineEventController)
@ -405,7 +453,7 @@ class RoomDetailFragment :
R.drawable.ic_reply, R.drawable.ic_reply,
object : RoomMessageTouchHelperCallback.QuickReplayHandler { object : RoomMessageTouchHelperCallback.QuickReplayHandler {
override fun performQuickReplyOnHolder(model: EpoxyModel<*>) { override fun performQuickReplyOnHolder(model: EpoxyModel<*>) {
(model as? AbsMessageItem)?.informationData?.let { (model as? AbsMessageItem)?.attributes?.informationData?.let {
val eventId = it.eventId val eventId = it.eventId
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId)) roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId))
} }
@ -416,7 +464,7 @@ class RoomDetailFragment :
is MessageFileItem, is MessageFileItem,
is MessageImageVideoItem, is MessageImageVideoItem,
is MessageTextItem -> { is MessageTextItem -> {
return (model as AbsMessageItem).informationData.sendState == SendState.SYNCED return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED
} }
else -> false else -> false
} }
@ -585,7 +633,7 @@ class RoomDetailFragment :
val summary = state.asyncRoomSummary() val summary = state.asyncRoomSummary()
val inviter = state.asyncInviter() val inviter = state.asyncInviter()
if (summary?.membership == Membership.JOIN) { if (summary?.membership == Membership.JOIN) {
timelineEventController.setTimeline(state.timeline, state.eventId) timelineEventController.setTimeline(state.timeline, state.highlightedEventId)
inviteView.visibility = View.GONE inviteView.visibility = View.GONE
val uid = session.myUserId val uid = session.myUserId
val meMember = session.getRoom(state.roomId)?.getRoomMember(uid) val meMember = session.getRoom(state.roomId)?.getRoomMember(uid)
@ -608,10 +656,12 @@ class RoomDetailFragment :
composerLayout.visibility = View.GONE composerLayout.visibility = View.GONE
notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent)) notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent))
} }
jumpToReadMarkerView.render(state.showJumpToReadMarker, summary?.readMarkerId)
} }


private fun renderRoomSummary(state: RoomDetailViewState) { private fun renderRoomSummary(state: RoomDetailViewState) {
state.asyncRoomSummary()?.let { state.asyncRoomSummary()?.let {

if (it.membership.isLeft()) { if (it.membership.isLeft()) {
Timber.w("The room has been left") Timber.w("The room has been left")
activity?.finish() activity?.finish()
@ -684,7 +734,7 @@ class RoomDetailFragment :
.show() .show()
} }


// TimelineEventController.Callback ************************************************************ // TimelineEventController.Callback ************************************************************


override fun onUrlClicked(url: String): Boolean { override fun onUrlClicked(url: String): Boolean {
return permalinkHandler.launch(requireActivity(), url, object : NavigateToRoomInterceptor { return permalinkHandler.launch(requireActivity(), url, object : NavigateToRoomInterceptor {
@ -696,7 +746,7 @@ class RoomDetailFragment :
showSnackWithMessage(getString(R.string.navigate_to_room_when_already_in_the_room)) showSnackWithMessage(getString(R.string.navigate_to_room_when_already_in_the_room))
} else { } else {
// Highlight and scroll to this event // Highlight and scroll to this event
roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(eventId, timelineEventController.searchPositionOfEvent(eventId))) roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(eventId, true))
} }
return true return true
} }
@ -716,7 +766,11 @@ class RoomDetailFragment :
} }


override fun onEventVisible(event: TimelineEvent) { 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) { override fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) {
@ -836,7 +890,15 @@ class RoomDetailFragment :
.show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS") .show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS")
} }


// AutocompleteUserPresenter.Callback override fun onReadMarkerLongDisplayed(informationData: MessageInformationData) {
val firstVisibleItem = layoutManager.findFirstVisibleItemPosition()
val eventId = timelineEventController.searchEventIdAtPosition(firstVisibleItem)
if (eventId != null) {
roomDetailViewModel.process(RoomDetailActions.SetReadMarkerAction(eventId))
}
}

// AutocompleteUserPresenter.Callback


override fun onQueryUsers(query: CharSequence?) { override fun onQueryUsers(query: CharSequence?) {
textComposerViewModel.process(TextComposerActions.QueryUsers(query)) textComposerViewModel.process(TextComposerActions.QueryUsers(query))
@ -1001,7 +1063,7 @@ class RoomDetailFragment :
snack.show() snack.show()
} }


// VectorInviteView.Callback // VectorInviteView.Callback


override fun onAcceptInvite() { override fun onAcceptInvite() {
notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId)
@ -1012,4 +1074,16 @@ class RoomDetailFragment :
notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) notificationDrawerManager.clearMemberShipNotificationForRoom(roomDetailArgs.roomId)
roomDetailViewModel.process(RoomDetailActions.RejectInvite) 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

@ -38,6 +38,7 @@ import im.vector.matrix.android.api.session.events.model.isTextMessage
import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.file.FileService import im.vector.matrix.android.api.session.file.FileService
import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.message.getFileUrl import im.vector.matrix.android.api.session.room.model.message.getFileUrl
@ -58,6 +59,8 @@ import im.vector.riotx.features.command.CommandParser
import im.vector.riotx.features.command.ParsedCommand import im.vector.riotx.features.command.ParsedCommand
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDisplayableEvents import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDisplayableEvents
import im.vector.riotx.features.settings.VectorPreferences import im.vector.riotx.features.settings.VectorPreferences
import io.reactivex.Observable
import io.reactivex.functions.Function3
import io.reactivex.rxkotlin.subscribeBy import io.reactivex.rxkotlin.subscribeBy
import org.commonmark.parser.Parser import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer import org.commonmark.renderer.html.HtmlRenderer
@ -75,7 +78,8 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
private val room = session.getRoom(initialState.roomId)!! private val room = session.getRoom(initialState.roomId)!!
private val roomId = initialState.roomId private val roomId = initialState.roomId
private val eventId = initialState.eventId 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()) { private val timelineSettings = if (userPreferencesProvider.shouldShowHiddenEvents()) {
TimelineSettings(30, false, true, TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES, userPreferencesProvider.shouldShowReadReceipts()) TimelineSettings(30, false, true, TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES, userPreferencesProvider.shouldShowReadReceipts())
} else { } else {
@ -109,6 +113,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
observeRoomSummary() observeRoomSummary()
observeEventDisplayedActions() observeEventDisplayedActions()
observeSummaryState() observeSummaryState()
observeJumpToReadMarkerViewVisibility()
room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear() room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear()
timeline.start() timeline.start()
setState { copy(timeline = this@RoomDetailViewModel.timeline) } setState { copy(timeline = this@RoomDetailViewModel.timeline) }
@ -116,30 +121,37 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro


fun process(action: RoomDetailActions) { fun process(action: RoomDetailActions) {
when (action) { when (action) {
is RoomDetailActions.SendMessage -> handleSendMessage(action) is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.SendMedia -> handleSendMedia(action) is RoomDetailActions.SendMedia -> handleSendMedia(action)
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action) is RoomDetailActions.TimelineEventTurnsVisible -> handleEventVisible(action)
is RoomDetailActions.LoadMoreTimelineEvents -> handleLoadMore(action) is RoomDetailActions.TimelineEventTurnsInvisible -> handleEventInvisible(action)
is RoomDetailActions.SendReaction -> handleSendReaction(action) is RoomDetailActions.LoadMoreTimelineEvents -> handleLoadMore(action)
is RoomDetailActions.AcceptInvite -> handleAcceptInvite() is RoomDetailActions.SendReaction -> handleSendReaction(action)
is RoomDetailActions.RejectInvite -> handleRejectInvite() is RoomDetailActions.AcceptInvite -> handleAcceptInvite()
is RoomDetailActions.RedactAction -> handleRedactEvent(action) is RoomDetailActions.RejectInvite -> handleRejectInvite()
is RoomDetailActions.UndoReaction -> handleUndoReact(action) is RoomDetailActions.RedactAction -> handleRedactEvent(action)
is RoomDetailActions.UpdateQuickReactAction -> handleUpdateQuickReaction(action) is RoomDetailActions.UndoReaction -> handleUndoReact(action)
is RoomDetailActions.EnterEditMode -> handleEditAction(action) is RoomDetailActions.UpdateQuickReactAction -> handleUpdateQuickReaction(action)
is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action) is RoomDetailActions.EnterEditMode -> handleEditAction(action)
is RoomDetailActions.EnterReplyMode -> handleReplyAction(action) is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action)
is RoomDetailActions.DownloadFile -> handleDownloadFile(action) is RoomDetailActions.EnterReplyMode -> handleReplyAction(action)
is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action) is RoomDetailActions.DownloadFile -> handleDownloadFile(action)
is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(action) is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action)
is RoomDetailActions.ResendMessage -> handleResendEvent(action) is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(action)
is RoomDetailActions.RemoveFailedEcho -> handleRemove(action) is RoomDetailActions.ResendMessage -> handleResendEvent(action)
is RoomDetailActions.ClearSendQueue -> handleClearSendQueue() is RoomDetailActions.RemoveFailedEcho -> handleRemove(action)
is RoomDetailActions.ResendAll -> handleResendAll() is RoomDetailActions.ClearSendQueue -> handleClearSendQueue()
else -> Timber.e("Unhandled Action: $action") is RoomDetailActions.ResendAll -> handleResendAll()
is RoomDetailActions.SetReadMarkerAction -> handleSetReadMarkerAction(action)
is RoomDetailActions.MarkAllAsRead -> handleMarkAllAsRead()
else -> Timber.e("Unhandled Action: $action")
} }
} }


private fun handleEventInvisible(action: RoomDetailActions.TimelineEventTurnsInvisible) {
invisibleEventsObservable.accept(action)
}

private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) { private fun handleTombstoneEvent(action: RoomDetailActions.HandleTombstoneEvent) {
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>() val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>()
?: return ?: return
@ -444,14 +456,14 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
room.sendMedias(attachments) room.sendMedias(attachments)
} }


private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) { private fun handleEventVisible(action: RoomDetailActions.TimelineEventTurnsVisible) {
if (action.event.root.sendState.isSent()) { //ignore pending/local events 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) //We need to update this with the related m.replace also (to move read receipt)
action.event.annotations?.editSummary?.sourceEvents?.forEach { action.event.annotations?.editSummary?.sourceEvents?.forEach {
room.getTimeLineEvent(it)?.let { event -> room.getTimeLineEvent(it)?.let { event ->
displayedEventsObservable.accept(RoomDetailActions.EventDisplayed(event)) visibleEventsObservable.accept(RoomDetailActions.TimelineEventTurnsVisible(event))
} }
} }
} }
@ -494,11 +506,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} }
} }


data class DownloadFileState(
val mimeType: String,
val file: File?,
val throwable: Throwable?
)


private fun handleDownloadFile(action: RoomDetailActions.DownloadFile) { private fun handleDownloadFile(action: RoomDetailActions.DownloadFile) {
session.downloadFile( session.downloadFile(
@ -530,53 +537,15 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro


private fun handleNavigateToEvent(action: RoomDetailActions.NavigateToEvent) { private fun handleNavigateToEvent(action: RoomDetailActions.NavigateToEvent) {
val targetEventId = action.eventId val targetEventId = action.eventId

val indexOfEvent = timeline.getIndexOfEvent(targetEventId)
if (action.position != null) { if (indexOfEvent == null) {
// Event is already in RAM // Event is not already in RAM
withState { timeline.restartWithEventId(targetEventId)
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)
} }
if (action.highlight) {
setState { copy(highlightedEventId = targetEventId) }
}
_navigateToEvent.postLiveEvent(targetEventId)
} }


private fun handleResendEvent(action: RoomDetailActions.ResendMessage) { private fun handleResendEvent(action: RoomDetailActions.ResendMessage) {
@ -622,7 +591,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
private fun observeEventDisplayedActions() { private fun observeEventDisplayedActions() {
// We are buffering scroll events for one second // We are buffering scroll events for one second
// and keep the most recent one to set the read receipt on. // and keep the most recent one to set the read receipt on.
displayedEventsObservable visibleEventsObservable
.buffer(1, TimeUnit.SECONDS) .buffer(1, TimeUnit.SECONDS)
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
.subscribeBy(onNext = { actions -> .subscribeBy(onNext = { actions ->
@ -634,6 +603,24 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
.disposeOnClear() .disposeOnClear()
} }


private fun handleSetReadMarkerAction(action: RoomDetailActions.SetReadMarkerAction) = withState { state ->
var readMarkerId = action.eventId
if (readMarkerId == state.asyncRoomSummary()?.readMarkerId) {
val indexOfEvent = timeline.getIndexOfEvent(action.eventId)
// force to set the read marker on the next event
if (indexOfEvent != null) {
timeline.getTimelineEventAtIndex(indexOfEvent - 1)?.root?.eventId?.also { eventIdOfNext ->
readMarkerId = eventIdOfNext
}
}
}
room.setReadMarker(readMarkerId, callback = object : MatrixCallback<Unit> {})
}

private fun handleMarkAllAsRead() {
room.markAllAsRead(object : MatrixCallback<Any> {})
}

private fun observeSyncState() { private fun observeSyncState() {
session.rx() session.rx()
.liveSyncState() .liveSyncState()
@ -645,6 +632,39 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
.disposeOnClear() .disposeOnClear()
} }


private fun observeJumpToReadMarkerViewVisibility() {
Observable
.combineLatest(
room.rx().liveRoomSummary(),
visibleEventsObservable.distinctUntilChanged(),
isEventVisibleObservable { it.hasReadMarker }.startWith(false),
Function3<RoomSummary, RoomDetailActions.TimelineEventTurnsVisible, Boolean, Boolean> { roomSummary, currentVisibleEvent, isReadMarkerViewVisible ->
val readMarkerId = roomSummary.readMarkerId
if (readMarkerId == null || isReadMarkerViewVisible || !timeline.isLive) {
false
} else {
val readMarkerPosition = timeline.getIndexOfEvent(readMarkerId)
?: Int.MAX_VALUE
val currentVisibleEventPosition = timeline.getIndexOfEvent(currentVisibleEvent.event.root.eventId)
?: Int.MIN_VALUE
readMarkerPosition > currentVisibleEventPosition
}
}
)
.distinctUntilChanged()
.subscribe {
setState { copy(showJumpToReadMarker = it) }
}
.disposeOnClear()
}

private fun isEventVisibleObservable(filterEvent: (TimelineEvent) -> Boolean): Observable<Boolean> {
return Observable.merge(
visibleEventsObservable.filter { filterEvent(it.event) }.map { true },
invisibleEventsObservable.filter { filterEvent(it.event) }.map { false }
)
}

private fun observeRoomSummary() { private fun observeRoomSummary() {
room.rx().liveRoomSummary() room.rx().liveRoomSummary()
.execute { async -> .execute { async ->

View File

@ -51,7 +51,9 @@ data class RoomDetailViewState(
val isEncrypted: Boolean = false, val isEncrypted: Boolean = false,
val tombstoneEvent: Event? = null, val tombstoneEvent: Event? = null,
val tombstoneEventHandling: Async<String> = Uninitialized, val tombstoneEventHandling: Async<String> = Uninitialized,
val syncState: SyncState = SyncState.IDLE val syncState: SyncState = SyncState.IDLE,
val showJumpToReadMarker: Boolean = false,
val highlightedEventId: String? = null
) : MvRxState { ) : MvRxState {


constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId) constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId)

View File

@ -38,7 +38,7 @@ class ScrollOnHighlightedEventCallback(private val layoutManager: LinearLayoutMa
// Do not scroll it item is already visible // Do not scroll it item is already visible
if (positionToScroll !in firstVisibleItem..lastVisibleItem) { if (positionToScroll !in firstVisibleItem..lastVisibleItem) {
// Note: Offset will be from the bottom, since the layoutManager is reversed // Note: Offset will be from the bottom, since the layoutManager is reversed
layoutManager.scrollToPositionWithOffset(positionToScroll, 120) layoutManager.scrollToPosition(position)
} }
scheduledEventId.set(null) scheduledEventId.set(null)
} }

View File

@ -49,11 +49,11 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
private val timelineMediaSizeProvider: TimelineMediaSizeProvider, private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val avatarRenderer: AvatarRenderer, private val avatarRenderer: AvatarRenderer,
@TimelineEventControllerHandler @TimelineEventControllerHandler
private val backgroundHandler: Handler, private val backgroundHandler: Handler
userPreferencesProvider: UserPreferencesProvider
) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener { ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener {


interface Callback : BaseCallback, ReactionPillCallback, AvatarCallback, UrlClickCallback, ReadReceiptsCallback { interface Callback : BaseCallback, ReactionPillCallback, AvatarCallback, UrlClickCallback, ReadReceiptsCallback {
fun onEventInvisible(event: TimelineEvent)
fun onEventVisible(event: TimelineEvent) fun onEventVisible(event: TimelineEvent)
fun onRoomCreateLinkClicked(url: String) fun onRoomCreateLinkClicked(url: String)
fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View)
@ -81,6 +81,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec


interface ReadReceiptsCallback { interface ReadReceiptsCallback {
fun onReadReceiptsClicked(readReceipts: List<ReadReceiptData>) fun onReadReceiptsClicked(readReceipts: List<ReadReceiptData>)
fun onReadMarkerLongDisplayed(informationData: MessageInformationData)
} }


interface UrlClickCallback { interface UrlClickCallback {
@ -140,8 +141,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
} }
} }


private val showHiddenEvents = userPreferencesProvider.shouldShowHiddenEvents()

init { init {
requestModelBuild() requestModelBuild()
} }
@ -247,7 +246,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec


private fun buildItemModels(currentPosition: Int, items: List<TimelineEvent>): CacheItemData { private fun buildItemModels(currentPosition: Int, items: List<TimelineEvent>): CacheItemData {
val event = items[currentPosition] val event = items[currentPosition]
val nextEvent = items.nextDisplayableEvent(currentPosition, showHiddenEvents) val nextEvent = items.nextOrNull(currentPosition)
val date = event.root.localDateTime() val date = event.root.localDateTime()
val nextDate = nextEvent?.root?.localDateTime() val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
@ -327,24 +326,50 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
return shouldAdd return shouldAdd
} }


fun searchPositionOfEvent(eventId: String): Int? { fun searchPositionOfEvent(eventId: String): Int? = synchronized(modelCache) {
synchronized(modelCache) { // Search in the cache
// Search in the cache var realPosition = 0
modelCache.forEachIndexed { idx, cacheItemData -> for (i in 0 until modelCache.size) {
if (cacheItemData?.eventId == eventId) { val itemCache = modelCache[i]
return idx if (itemCache?.eventId == eventId) {
} return realPosition
}
if (itemCache?.eventModel != null) {
realPosition++
}
if (itemCache?.mergedHeaderModel != null) {
realPosition++
}
if (itemCache?.formattedDayModel != null) {
realPosition++
} }

return null
} }
return null
} }
}


private data class CacheItemData( fun searchEventIdAtPosition(position: Int): String? = synchronized(modelCache) {
val localId: Long, var offsetValue = 0
val eventId: String?, for (i in 0 until position) {
val eventModel: EpoxyModel<*>? = null, val itemCache = modelCache[i]
val mergedHeaderModel: MergedHeaderItem? = null, if (itemCache?.eventModel == null) {
val formattedDayModel: DaySeparatorItem? = null offsetValue--
) }
if (itemCache?.mergedHeaderModel != null) {
offsetValue++
}
if (itemCache?.formattedDayModel != null) {
offsetValue++
}
}
return modelCache.getOrNull(position - offsetValue)?.eventId
}

private data class CacheItemData(
val localId: Long,
val eventId: String?,
val eventModel: EpoxyModel<*>? = null,
val mergedHeaderModel: MergedHeaderItem? = null,
val formattedDayModel: DaySeparatorItem? = null
)

}

View File

@ -16,7 +16,6 @@


package im.vector.riotx.features.home.room.detail.timeline.factory 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.crypto.MXCryptoError
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
@ -24,11 +23,11 @@ import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem_ import im.vector.riotx.features.home.room.detail.timeline.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 me.gujun.android.span.span
import javax.inject.Inject import javax.inject.Inject


@ -36,7 +35,7 @@ import javax.inject.Inject
class EncryptedItemFactory @Inject constructor(private val messageInformationDataFactory: MessageInformationDataFactory, class EncryptedItemFactory @Inject constructor(private val messageInformationDataFactory: MessageInformationDataFactory,
private val colorProvider: ColorProvider, private val colorProvider: ColorProvider,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val avatarRenderer: AvatarRenderer) { private val attributesFactory: MessageItemAttributesFactory) {


fun create(event: TimelineEvent, fun create(event: TimelineEvent,
nextEvent: TimelineEvent?, nextEvent: TimelineEvent?,
@ -65,22 +64,13 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat
// TODO This is not correct format for error, change it // TODO This is not correct format for error, change it


val informationData = messageInformationDataFactory.create(event, nextEvent) val informationData = messageInformationDataFactory.create(event, nextEvent)
val attributes = attributesFactory.create(null, informationData, callback)
return MessageTextItem_() return MessageTextItem_()
.attributes(attributes)
.message(spannableStr) .message(spannableStr)
.avatarRenderer(avatarRenderer)
.colorProvider(colorProvider)
.informationData(informationData)
.highlighted(highlight) .highlighted(highlight)
.avatarCallback(callback)
.urlClickCallback(callback) .urlClickCallback(callback)
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEncryptedMessageClicked(informationData, view)
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, null, view)
?: false
}
} }
else -> null else -> null
} }

View File

@ -1,71 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.riotx.features.home.room.detail.timeline.factory

import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.crypto.model.event.EncryptionEventContent
import im.vector.riotx.R
import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar
import im.vector.riotx.features.home.room.detail.timeline.helper.senderName
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem
import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_
import javax.inject.Inject

class EncryptionItemFactory @Inject constructor(private val stringProvider: StringProvider,
private val avatarRenderer: AvatarRenderer) {

fun create(event: TimelineEvent,
highlight: Boolean,
callback: TimelineEventController.BaseCallback?): NoticeItem? {

val text = buildNoticeText(event.root, event.senderName) ?: return null
val informationData = MessageInformationData(
eventId = event.root.eventId ?: "?",
senderId = event.root.senderId ?: "",
sendState = event.root.sendState,
avatarUrl = event.senderAvatar(),
memberName = event.senderName(),
showInformation = false
)
return NoticeItem_()
.avatarRenderer(avatarRenderer)
.noticeText(text)
.informationData(informationData)
.highlighted(highlight)
.baseCallback(callback)
}

private fun buildNoticeText(event: Event, senderName: String?): CharSequence? {
return when {
EventType.ENCRYPTION == event.getClearType() -> {
val content = event.content.toModel<EncryptionEventContent>() ?: return null
stringProvider.getString(R.string.notice_end_to_end, senderName, content.algorithm)
}
else -> null
}

}


}

View File

@ -47,27 +47,14 @@ import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.linkify.VectorLinkify import im.vector.riotx.core.linkify.VectorLinkify
import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.core.resources.UserPreferencesProvider
import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.features.home.AvatarRenderer 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.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar import im.vector.riotx.features.home.room.detail.timeline.item.*
import im.vector.riotx.features.home.room.detail.timeline.item.BlankItem_ 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.helper.MessageItemAttributesFactory
import im.vector.riotx.features.home.room.detail.timeline.item.DefaultItem_
import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem
import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem_
import im.vector.riotx.features.home.room.detail.timeline.item.MessageImageVideoItem
import im.vector.riotx.features.home.room.detail.timeline.item.MessageImageVideoItem_
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem
import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem_
import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_
import im.vector.riotx.features.home.room.detail.timeline.item.RedactedMessageItem
import im.vector.riotx.features.home.room.detail.timeline.item.RedactedMessageItem_
import im.vector.riotx.features.home.room.detail.timeline.util.MessageInformationDataFactory
import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.html.EventHtmlRenderer
import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.ImageContentRenderer
import im.vector.riotx.features.media.VideoContentRenderer import im.vector.riotx.features.media.VideoContentRenderer
@ -75,14 +62,13 @@ import me.gujun.android.span.span
import javax.inject.Inject import javax.inject.Inject


class MessageItemFactory @Inject constructor( class MessageItemFactory @Inject constructor(
private val avatarRenderer: AvatarRenderer,
private val colorProvider: ColorProvider, private val colorProvider: ColorProvider,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider, private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val htmlRenderer: Lazy<EventHtmlRenderer>, private val htmlRenderer: Lazy<EventHtmlRenderer>,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val emojiCompatFontProvider: EmojiCompatFontProvider,
private val imageContentRenderer: ImageContentRenderer, private val imageContentRenderer: ImageContentRenderer,
private val messageInformationDataFactory: MessageInformationDataFactory, private val messageInformationDataFactory: MessageInformationDataFactory,
private val messageItemAttributesFactory: MessageItemAttributesFactory,
private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder, private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder,
private val noticeItemFactory: NoticeItemFactory) { private val noticeItemFactory: NoticeItemFactory) {


@ -98,36 +84,41 @@ class MessageItemFactory @Inject constructor(


if (event.root.isRedacted()) { if (event.root.isRedacted()) {
//message is redacted //message is redacted
return buildRedactedItem(informationData, highlight, callback) val attributes = messageItemAttributesFactory.create(null, informationData, callback)
return buildRedactedItem(attributes, highlight)
} }


val messageContent: MessageContent = val messageContent: MessageContent =
event.getLastMessageContent() event.getLastMessageContent()
?: //Malformed content, we should echo something on screen ?: //Malformed content, we should echo something on screen
return DefaultItem_().text(stringProvider.getString(R.string.malformed_message)) return DefaultItem_().text(stringProvider.getString(R.string.malformed_message))


if (messageContent.relatesTo?.type == RelationType.REPLACE 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 it when debugging as a notice event // This is an edit event, we should it when debugging as a notice event
return noticeItemFactory.create(event, highlight, callback) return noticeItemFactory.create(event, highlight, callback)
} }
val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback)

// val all = event.root.toContent() // val all = event.root.toContent()
// val ev = all.toModel<Event>() // val ev = all.toModel<Event>()
return when (messageContent) { return when (messageContent) {
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, is MessageEmoteContent -> buildEmoteMessageItem(messageContent,
informationData, informationData,
highlight, highlight,
callback) callback,
attributes)
is MessageTextContent -> buildTextMessageItem(messageContent, is MessageTextContent -> buildTextMessageItem(messageContent,
informationData, informationData,
highlight, highlight,
callback) callback,
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, highlight, callback) attributes)
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback) is MessageImageContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback) is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageFileContent -> buildFileMessageItem(messageContent, informationData, highlight, callback) is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, callback) is MessageFileContent -> buildFileMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, callback, attributes)
else -> buildNotHandledMessageItem(messageContent, highlight) else -> buildNotHandledMessageItem(messageContent, highlight)
} }
} }
@ -135,55 +126,29 @@ class MessageItemFactory @Inject constructor(
private fun buildAudioMessageItem(messageContent: MessageAudioContent, private fun buildAudioMessageItem(messageContent: MessageAudioContent,
informationData: MessageInformationData, informationData: MessageInformationData,
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?): MessageFileItem? { callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageFileItem? {
return MessageFileItem_() return MessageFileItem_()
.avatarRenderer(avatarRenderer) .attributes(attributes)
.colorProvider(colorProvider)
.informationData(informationData)
.highlighted(highlight) .highlighted(highlight)
.avatarCallback(callback)
.readReceiptsCallback(callback)
.filename(messageContent.body) .filename(messageContent.body)
.iconRes(R.drawable.filetype_audio) .iconRes(R.drawable.filetype_audio)
.reactionPillCallback(callback)
.emojiTypeFace(emojiCompatFontProvider.typeface)
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view: View ->
callback?.onEventCellClicked(informationData, messageContent, view)
}))
.clickListener( .clickListener(
DebouncedClickListener(View.OnClickListener { DebouncedClickListener(View.OnClickListener {
callback?.onAudioMessageClicked(messageContent) callback?.onAudioMessageClicked(messageContent)
})) }))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
}
} }


private fun buildFileMessageItem(messageContent: MessageFileContent, private fun buildFileMessageItem(messageContent: MessageFileContent,
informationData: MessageInformationData, informationData: MessageInformationData,
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?): MessageFileItem? { callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageFileItem? {
return MessageFileItem_() return MessageFileItem_()
.avatarRenderer(avatarRenderer) .attributes(attributes)
.colorProvider(colorProvider)
.informationData(informationData)
.highlighted(highlight) .highlighted(highlight)
.avatarCallback(callback)
.filename(messageContent.body) .filename(messageContent.body)
.reactionPillCallback(callback)
.readReceiptsCallback(callback)
.emojiTypeFace(emojiCompatFontProvider.typeface)
.iconRes(R.drawable.filetype_attachment) .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( .clickListener(
DebouncedClickListener(View.OnClickListener { _ -> DebouncedClickListener(View.OnClickListener { _ ->
callback?.onFileMessageClicked(informationData.eventId, messageContent) callback?.onFileMessageClicked(informationData.eventId, messageContent)
@ -200,7 +165,8 @@ class MessageItemFactory @Inject constructor(
private fun buildImageMessageItem(messageContent: MessageImageContent, private fun buildImageMessageItem(messageContent: MessageImageContent,
informationData: MessageInformationData, informationData: MessageInformationData,
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?): MessageImageVideoItem? { callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageImageVideoItem? {


val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize() val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
val data = ImageContentRenderer.Data( val data = ImageContentRenderer.Data(
@ -215,42 +181,29 @@ class MessageItemFactory @Inject constructor(
rotation = messageContent.info?.rotation rotation = messageContent.info?.rotation
) )
return MessageImageVideoItem_() return MessageImageVideoItem_()
.avatarRenderer(avatarRenderer) .attributes(attributes)
.colorProvider(colorProvider)
.imageContentRenderer(imageContentRenderer) .imageContentRenderer(imageContentRenderer)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.playable(messageContent.info?.mimeType == "image/gif") .playable(messageContent.info?.mimeType == "image/gif")
.informationData(informationData)
.highlighted(highlight) .highlighted(highlight)
.avatarCallback(callback)
.mediaData(data) .mediaData(data)
.reactionPillCallback(callback)
.readReceiptsCallback(callback)
.emojiTypeFace(emojiCompatFontProvider.typeface)
.clickListener( .clickListener(
DebouncedClickListener(View.OnClickListener { view -> DebouncedClickListener(View.OnClickListener { view ->
callback?.onImageMessageClicked(messageContent, data, 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, private fun buildVideoMessageItem(messageContent: MessageVideoContent,
informationData: MessageInformationData, informationData: MessageInformationData,
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?): MessageImageVideoItem? { callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageImageVideoItem? {


val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize() val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
val thumbnailData = ImageContentRenderer.Data( val thumbnailData = ImageContentRenderer.Data(
filename = messageContent.body, filename = messageContent.body,
url = messageContent.videoInfo?.thumbnailFile?.url url = messageContent.videoInfo?.thumbnailFile?.url
?: messageContent.videoInfo?.thumbnailUrl, ?: messageContent.videoInfo?.thumbnailUrl,
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = messageContent.videoInfo?.height, height = messageContent.videoInfo?.height,
maxHeight = maxHeight, maxHeight = maxHeight,
@ -267,33 +220,20 @@ class MessageItemFactory @Inject constructor(
) )


return MessageImageVideoItem_() return MessageImageVideoItem_()
.attributes(attributes)
.imageContentRenderer(imageContentRenderer) .imageContentRenderer(imageContentRenderer)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.avatarRenderer(avatarRenderer)
.colorProvider(colorProvider)
.playable(true) .playable(true)
.informationData(informationData)
.highlighted(highlight) .highlighted(highlight)
.avatarCallback(callback)
.mediaData(thumbnailData) .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) } .clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view) }
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
}
} }


private fun buildTextMessageItem(messageContent: MessageTextContent, private fun buildTextMessageItem(messageContent: MessageTextContent,
informationData: MessageInformationData, informationData: MessageInformationData,
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?): MessageTextItem? { callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageTextItem? {


val bodyToUse = messageContent.formattedBody?.let { val bodyToUse = messageContent.formattedBody?.let {
htmlRenderer.get().render(it.trim()) htmlRenderer.get().render(it.trim())
@ -310,24 +250,10 @@ class MessageItemFactory @Inject constructor(
message(linkifiedBody) message(linkifiedBody)
} }
} }
.avatarRenderer(avatarRenderer) .attributes(attributes)
.informationData(informationData)
.colorProvider(colorProvider)
.highlighted(highlight) .highlighted(highlight)
.avatarCallback(callback)
.urlClickCallback(callback) .urlClickCallback(callback)
.reactionPillCallback(callback) //click on the text
.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
}
} }


private fun annotateWithEdited(linkifiedBody: CharSequence, private fun annotateWithEdited(linkifiedBody: CharSequence,
@ -356,16 +282,17 @@ class MessageItemFactory @Inject constructor(
//nop //nop
} }
}, },
editStart, editStart,
editEnd, editEnd,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE) Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
return spannable return spannable
} }


private fun buildNoticeMessageItem(messageContent: MessageNoticeContent, private fun buildNoticeMessageItem(messageContent: MessageNoticeContent,
informationData: MessageInformationData, informationData: MessageInformationData,
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?): MessageTextItem? { callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageTextItem? {


val message = messageContent.body.let { val message = messageContent.body.let {
val formattedBody = span { val formattedBody = span {
@ -376,34 +303,17 @@ class MessageItemFactory @Inject constructor(
linkifyBody(formattedBody, callback) linkifyBody(formattedBody, callback)
} }
return MessageTextItem_() return MessageTextItem_()
.avatarRenderer(avatarRenderer) .attributes(attributes)
.message(message) .message(message)
.colorProvider(colorProvider)
.informationData(informationData)
.highlighted(highlight) .highlighted(highlight)
.avatarCallback(callback)
.reactionPillCallback(callback)
.urlClickCallback(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, private fun buildEmoteMessageItem(messageContent: MessageEmoteContent,
informationData: MessageInformationData, informationData: MessageInformationData,
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?): MessageTextItem? { callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageTextItem? {


val message = messageContent.body.let { val message = messageContent.body.let {
val formattedBody = "* ${informationData.memberName} $it" val formattedBody = "* ${informationData.memberName} $it"
@ -418,43 +328,16 @@ class MessageItemFactory @Inject constructor(
message(message) message(message)
} }
} }
.avatarRenderer(avatarRenderer) .attributes(attributes)
.colorProvider(colorProvider)
.informationData(informationData)
.highlighted(highlight) .highlighted(highlight)
.avatarCallback(callback)
.reactionPillCallback(callback)
.readReceiptsCallback(callback)
.urlClickCallback(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, private fun buildRedactedItem(attributes: AbsMessageItem.Attributes,
highlight: Boolean, highlight: Boolean): RedactedMessageItem? {
callback: TimelineEventController.Callback?): RedactedMessageItem? {
return RedactedMessageItem_() return RedactedMessageItem_()
.avatarRenderer(avatarRenderer) .attributes(attributes)
.colorProvider(colorProvider)
.informationData(informationData)
.highlighted(highlight) .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 { private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): CharSequence {

View File

@ -20,12 +20,9 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController 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.format.NoticeEventFormatter
import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.riotx.features.home.room.detail.timeline.helper.senderName
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem
import 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 import javax.inject.Inject


class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEventFormatter, class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEventFormatter,

View File

@ -20,17 +20,11 @@ import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.core.epoxy.EmptyItem_ import im.vector.riotx.core.epoxy.EmptyItem_
import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_
import im.vector.riotx.features.home.room.detail.timeline.util.MessageInformationDataFactory
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject


class TimelineItemFactory @Inject constructor(private val messageItemFactory: MessageItemFactory, class TimelineItemFactory @Inject constructor(private val messageItemFactory: MessageItemFactory,
private val encryptionItemFactory: EncryptionItemFactory,
private val encryptedItemFactory: EncryptedItemFactory, private val encryptedItemFactory: EncryptedItemFactory,
private val noticeItemFactory: NoticeItemFactory, private val noticeItemFactory: NoticeItemFactory,
private val defaultItemFactory: DefaultItemFactory, private val defaultItemFactory: DefaultItemFactory,
@ -40,6 +34,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
nextEvent: TimelineEvent?, nextEvent: TimelineEvent?,
eventIdToHighlight: String?, eventIdToHighlight: String?,
callback: TimelineEventController.Callback?): VectorEpoxyModel<*> { callback: TimelineEventController.Callback?): VectorEpoxyModel<*> {

val highlight = event.root.eventId == eventIdToHighlight val highlight = event.root.eventId == eventIdToHighlight


val computedModel = try { val computedModel = try {
@ -55,11 +50,11 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
EventType.CALL_HANGUP, EventType.CALL_HANGUP,
EventType.CALL_ANSWER, EventType.CALL_ANSWER,
EventType.REACTION, EventType.REACTION,
EventType.REDACTION -> noticeItemFactory.create(event, highlight, callback) EventType.REDACTION,
EventType.ENCRYPTION -> noticeItemFactory.create(event, highlight, callback)
// State room create // State room create
EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback) EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback)
// Crypto // Crypto
EventType.ENCRYPTION -> encryptionItemFactory.create(event, highlight, callback)
EventType.ENCRYPTED -> { EventType.ENCRYPTED -> {
if (event.root.isRedacted()) { if (event.root.isRedacted()) {
// Redacted event, let the MessageItemFactory handle it // Redacted event, let the MessageItemFactory handle it

View File

@ -23,6 +23,7 @@ import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.* import im.vector.matrix.android.api.session.room.model.*
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.crypto.model.event.EncryptionEventContent
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.home.room.detail.timeline.helper.senderName import im.vector.riotx.features.home.room.detail.timeline.helper.senderName
@ -41,6 +42,7 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin
EventType.CALL_INVITE, EventType.CALL_INVITE,
EventType.CALL_HANGUP, EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> formatCallEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) EventType.CALL_ANSWER -> formatCallEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName())
EventType.ENCRYPTION -> formatEncryptionEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName())
EventType.MESSAGE, EventType.MESSAGE,
EventType.REACTION, EventType.REACTION,
EventType.REDACTION -> formatDebug(timelineEvent.root) EventType.REDACTION -> formatDebug(timelineEvent.root)
@ -60,6 +62,7 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin
EventType.CALL_INVITE, EventType.CALL_INVITE,
EventType.CALL_HANGUP, EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> formatCallEvent(event, senderName) EventType.CALL_ANSWER -> formatCallEvent(event, senderName)
EventType.ENCRYPTION -> formatEncryptionEvent(event, senderName)
EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(event, senderName) EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(event, senderName)
else -> { else -> {
Timber.v("Type $type not handled by this formatter") Timber.v("Type $type not handled by this formatter")
@ -96,7 +99,7 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin


private fun formatRoomHistoryVisibilityEvent(event: Event, senderName: String?): CharSequence? { private fun formatRoomHistoryVisibilityEvent(event: Event, senderName: String?): CharSequence? {
val historyVisibility = event.getClearContent().toModel<RoomHistoryVisibilityContent>()?.historyVisibility val historyVisibility = event.getClearContent().toModel<RoomHistoryVisibilityContent>()?.historyVisibility
?: return null ?: return null


val formattedVisibility = when (historyVisibility) { val formattedVisibility = when (historyVisibility) {
RoomHistoryVisibility.SHARED -> stringProvider.getString(R.string.notice_room_visibility_shared) RoomHistoryVisibility.SHARED -> stringProvider.getString(R.string.notice_room_visibility_shared)
@ -146,7 +149,7 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin
stringProvider.getString(R.string.notice_display_name_removed, event.senderId, prevEventContent?.displayName) stringProvider.getString(R.string.notice_display_name_removed, event.senderId, prevEventContent?.displayName)
else -> else ->
stringProvider.getString(R.string.notice_display_name_changed_from, stringProvider.getString(R.string.notice_display_name_changed_from,
event.senderId, prevEventContent?.displayName, eventContent?.displayName) event.senderId, prevEventContent?.displayName, eventContent?.displayName)
} }
displayText.append(displayNameText) displayText.append(displayNameText)
} }
@ -173,7 +176,7 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin
when { when {
eventContent.thirdPartyInvite != null -> eventContent.thirdPartyInvite != null ->
stringProvider.getString(R.string.notice_room_third_party_registered_invite, stringProvider.getString(R.string.notice_room_third_party_registered_invite,
targetDisplayName, eventContent.thirdPartyInvite?.displayName) targetDisplayName, eventContent.thirdPartyInvite?.displayName)
TextUtils.equals(event.stateKey, selfUserId) -> TextUtils.equals(event.stateKey, selfUserId) ->
stringProvider.getString(R.string.notice_room_invite_you, senderDisplayName) stringProvider.getString(R.string.notice_room_invite_you, senderDisplayName)
event.stateKey.isNullOrEmpty() -> event.stateKey.isNullOrEmpty() ->
@ -209,4 +212,9 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin
} }
} }


private fun formatEncryptionEvent(event: Event, senderName: String?): CharSequence? {
val eventContent: EncryptionEventContent = event.getClearContent().toModel() ?: return null
return stringProvider.getString(R.string.notice_end_to_end, senderName, eventContent.algorithm)
}

} }

View File

@ -1,20 +1,22 @@
/* /*
* Copyright 2019 New Vector Ltd
* * 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. * Licensed under the Apache License, Version 2.0 (the "License");
* You may obtain a copy of the License at * 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 *
* * 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, * Unless required by applicable law or agreed to in writing, software
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * distributed under the License is distributed on an "AS IS" BASIS,
* See the License for the specific language governing permissions and * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* limitations under the License. * 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.Session
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
@ -62,6 +64,9 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId ?: "")) textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId ?: ""))
} }


val displayReadMarker = event.hasReadMarker
&& event.readReceipts.find { it.user.userId == session.myUserId } == null

return MessageInformationData( return MessageInformationData(
eventId = eventId, eventId = eventId,
senderId = event.root.senderId ?: "", senderId = event.root.senderId ?: "",
@ -85,7 +90,8 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
.map { .map {
ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs) ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs)
} }
.toList() .toList(),
displayReadMarker = displayReadMarker
) )
} }
} }

View File

@ -0,0 +1,58 @@
/*

* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.

*/
package im.vector.riotx.features.home.room.detail.timeline.helper

import android.view.View
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.riotx.EmojiCompatFontProvider
import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import javax.inject.Inject

class MessageItemAttributesFactory @Inject constructor(
private val avatarRenderer: AvatarRenderer,
private val colorProvider: ColorProvider,
private val emojiCompatFontProvider: EmojiCompatFontProvider) {

fun create(messageContent: MessageContent?, informationData: MessageInformationData, callback: TimelineEventController.Callback?): AbsMessageItem.Attributes {
return AbsMessageItem.Attributes(
informationData = informationData,
avatarRenderer = avatarRenderer,
colorProvider = colorProvider,
itemLongClickListener = View.OnLongClickListener { view ->
callback?.onEventLongClicked(informationData, messageContent, view) ?: false
},
itemClickListener = DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
}),
memberClickListener = DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}),
reactionPillCallback = callback,
avatarCallback = callback,
readReceiptsCallback = callback,
emojiTypeFace = emojiCompatFontProvider.typeface
)

}

}

View File

@ -49,29 +49,6 @@ object TimelineDisplayableEvents {
) )
} }


fun TimelineEvent.isDisplayable(showHiddenEvent: Boolean): Boolean {
val allowed = TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES.takeIf { showHiddenEvent }
?: TimelineDisplayableEvents.DISPLAYABLE_TYPES
if (!allowed.contains(root.type)) {
return false
}
if (root.content.isNullOrEmpty()) {
return false
}
//Edits should be filtered out!
if (EventType.MESSAGE == root.type
&& root.content.toModel<MessageContent>()?.relatesTo?.type == RelationType.REPLACE) {
return false
}
return true
}
//
//fun List<TimelineEvent>.filterDisplayableEvents(): List<TimelineEvent> {
// return this.filter {
// it.isDisplayable()
// }
//}

fun TimelineEvent.senderAvatar(): String? { fun TimelineEvent.senderAvatar(): String? {
// We might have no avatar when user leave, so we try to get it from prevContent // We might have no avatar when user leave, so we try to get it from prevContent
return senderAvatar return senderAvatar
@ -131,10 +108,10 @@ fun List<TimelineEvent>.prevSameTypeEvents(index: Int, minSize: Int): List<Timel
.reversed() .reversed()
} }


fun List<TimelineEvent>.nextDisplayableEvent(index: Int, showHiddenEvent: Boolean): TimelineEvent? { fun List<TimelineEvent>.nextOrNull(index: Int): TimelineEvent? {
return if (index >= size - 1) { return if (index >= size - 1) {
null null
} else { } 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) { override fun onVisibilityStateChanged(visibilityState: Int) {
if (visibilityState == VisibilityState.VISIBLE) { if (visibilityState == VisibilityState.VISIBLE) {
callback?.onEventVisible(event) 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) { override fun onVisibilityStateChanged(visibilityState: Int) {
if (visibilityState == VisibilityState.VISIBLE) { if (visibilityState == VisibilityState.VISIBLE) {
events.forEach { events.forEach { callback?.onEventVisible(it) }
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.matrix.android.api.session.room.send.SendState
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.ui.views.ReadMarkerView
import im.vector.riotx.core.ui.views.ReadReceiptsView import im.vector.riotx.core.ui.views.ReadReceiptsView
import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.core.utils.DimensionUtils.dpToPx import im.vector.riotx.core.utils.DimensionUtils.dpToPx
@ -43,63 +44,42 @@ import im.vector.riotx.features.ui.getMessageTextColor
abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() { abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {


@EpoxyAttribute @EpoxyAttribute
lateinit var informationData: MessageInformationData lateinit var attributes: Attributes

@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


private val _avatarClickListener = DebouncedClickListener(View.OnClickListener { private val _avatarClickListener = DebouncedClickListener(View.OnClickListener {
avatarCallback?.onAvatarClicked(informationData) attributes.avatarCallback?.onAvatarClicked(attributes.informationData)
}) })
private val _memberNameClickListener = DebouncedClickListener(View.OnClickListener { private val _memberNameClickListener = DebouncedClickListener(View.OnClickListener {
avatarCallback?.onMemberNameClicked(informationData) attributes.avatarCallback?.onMemberNameClicked(attributes.informationData)
}) })


private val _readReceiptsClickListener = DebouncedClickListener(View.OnClickListener { private val _readReceiptsClickListener = DebouncedClickListener(View.OnClickListener {
readReceiptsCallback?.onReadReceiptsClicked(informationData.readReceipts) attributes.readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts)
}) })


private val _readMarkerCallback = object : ReadMarkerView.Callback {
override fun onReadMarkerDisplayed() {
attributes.readReceiptsCallback?.onReadMarkerLongDisplayed(attributes.informationData)
}
}

var reactionClickListener: ReactionButton.ReactedListener = object : ReactionButton.ReactedListener { var reactionClickListener: ReactionButton.ReactedListener = object : ReactionButton.ReactedListener {
override fun onReacted(reactionButton: ReactionButton) { override fun onReacted(reactionButton: ReactionButton) {
reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString, true) attributes.reactionPillCallback?.onClickOnReactionPill(attributes.informationData, reactionButton.reactionString, true)
} }


override fun onUnReacted(reactionButton: ReactionButton) { override fun onUnReacted(reactionButton: ReactionButton) {
reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString, false) attributes.reactionPillCallback?.onClickOnReactionPill(attributes.informationData, reactionButton.reactionString, false)
} }


override fun onLongClick(reactionButton: ReactionButton) { override fun onLongClick(reactionButton: ReactionButton) {
reactionPillCallback?.onLongClickOnReactionPill(informationData, reactionButton.reactionString) attributes.reactionPillCallback?.onLongClickOnReactionPill(attributes.informationData, reactionButton.reactionString)
} }
} }


override fun bind(holder: H) { override fun bind(holder: H) {
super.bind(holder) super.bind(holder)
if (informationData.showInformation) { if (attributes.informationData.showInformation) {
holder.avatarImageView.layoutParams = holder.avatarImageView.layoutParams?.apply { holder.avatarImageView.layoutParams = holder.avatarImageView.layoutParams?.apply {
val size = dpToPx(avatarStyle.avatarSizeDP, holder.view.context) val size = dpToPx(avatarStyle.avatarSizeDP, holder.view.context)
height = size height = size
@ -110,13 +90,13 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
holder.memberNameView.visibility = View.VISIBLE holder.memberNameView.visibility = View.VISIBLE
holder.memberNameView.setOnClickListener(_memberNameClickListener) holder.memberNameView.setOnClickListener(_memberNameClickListener)
holder.timeView.visibility = View.VISIBLE holder.timeView.visibility = View.VISIBLE
holder.timeView.text = informationData.time holder.timeView.text = attributes.informationData.time
holder.memberNameView.text = informationData.memberName holder.memberNameView.text = attributes.informationData.memberName
avatarRenderer.render(informationData.avatarUrl, informationData.senderId, informationData.memberName?.toString(), holder.avatarImageView) attributes.avatarRenderer.render(attributes.informationData.avatarUrl, attributes.informationData.senderId, attributes.informationData.memberName?.toString(), holder.avatarImageView)
holder.view.setOnClickListener(cellClickListener) holder.view.setOnClickListener(attributes.itemClickListener)
holder.view.setOnLongClickListener(longClickListener) holder.view.setOnLongClickListener(attributes.itemLongClickListener)
holder.avatarImageView.setOnLongClickListener(longClickListener) holder.avatarImageView.setOnLongClickListener(attributes.itemLongClickListener)
holder.memberNameView.setOnLongClickListener(longClickListener) holder.memberNameView.setOnLongClickListener(attributes.itemLongClickListener)
} else { } else {
holder.avatarImageView.setOnClickListener(null) holder.avatarImageView.setOnClickListener(null)
holder.memberNameView.setOnClickListener(null) holder.memberNameView.setOnClickListener(null)
@ -128,10 +108,10 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
holder.avatarImageView.setOnLongClickListener(null) holder.avatarImageView.setOnLongClickListener(null)
holder.memberNameView.setOnLongClickListener(null) holder.memberNameView.setOnLongClickListener(null)
} }
holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener)
holder.readMarkerView.bindView(attributes.informationData, _readMarkerCallback)


holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener) if (!shouldShowReactionAtBottom() || attributes.informationData.orderedReactionList.isNullOrEmpty()) {

if (!shouldShowReactionAtBottom() || informationData.orderedReactionList.isNullOrEmpty()) {
holder.reactionWrapper?.isVisible = false holder.reactionWrapper?.isVisible = false
} else { } else {
//inflate if needed //inflate if needed
@ -143,7 +123,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
//clear all reaction buttons (but not the Flow helper!) //clear all reaction buttons (but not the Flow helper!)
holder.reactionWrapper?.children?.forEach { (it as? ReactionButton)?.isGone = true } holder.reactionWrapper?.children?.forEach { (it as? ReactionButton)?.isGone = true }
val idToRefInFlow = ArrayList<Int>() 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 -> (holder.reactionWrapper?.children?.elementAtOrNull(index) as? ReactionButton)?.let { reactionButton ->
reactionButton.isVisible = true reactionButton.isVisible = true
reactionButton.reactedListener = reactionClickListener reactionButton.reactedListener = reactionClickListener
@ -151,7 +131,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
idToRefInFlow.add(reactionButton.id) idToRefInFlow.add(reactionButton.id)
reactionButton.reactionString = reaction.key reactionButton.reactionString = reaction.key
reactionButton.reactionCount = reaction.count reactionButton.reactionCount = reaction.count
reactionButton.emojiTypeFace = emojiTypeFace reactionButton.emojiTypeFace = attributes.emojiTypeFace
reactionButton.setChecked(reaction.addedByMe) reactionButton.setChecked(reaction.addedByMe)
reactionButton.isEnabled = reaction.synced reactionButton.isEnabled = reaction.synced
} }
@ -162,26 +142,48 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && !holder.view.isInLayout) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && !holder.view.isInLayout) {
holder.reactionFlowHelper?.requestLayout() 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 { open fun shouldShowReactionAtBottom(): Boolean {
return true return true
} }


protected open fun renderSendState(root: View, textView: TextView?, failureIndicator: ImageView? = null) { protected open fun renderSendState(root: View, textView: TextView?, failureIndicator: ImageView? = null) {
root.isClickable = informationData.sendState.isSent() root.isClickable = attributes.informationData.sendState.isSent()
val state = if (informationData.hasPendingEdits) SendState.UNSENT else informationData.sendState val state = if (attributes.informationData.hasPendingEdits) SendState.UNSENT else attributes.informationData.sendState
textView?.setTextColor(colorProvider.getMessageTextColor(state)) textView?.setTextColor(attributes.colorProvider.getMessageTextColor(state))
failureIndicator?.isVisible = informationData.sendState.hasFailed() failureIndicator?.isVisible = attributes.informationData.sendState.hasFailed()
} }


/**
* This class holds all the common attributes for message items.
*/
data class Attributes(
val informationData: MessageInformationData,
val avatarRenderer: AvatarRenderer,
val colorProvider: ColorProvider,
val itemLongClickListener: View.OnLongClickListener? = null,
val itemClickListener: View.OnClickListener? = null,
val memberClickListener: View.OnClickListener? = null,
val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null,
val avatarCallback: TimelineEventController.AvatarCallback? = null,
val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null,
val emojiTypeFace: Typeface? = null
)

abstract class Holder(@IdRes stubId: Int) : BaseHolder(stubId) { abstract class Holder(@IdRes stubId: Int) : BaseHolder(stubId) {
val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView) val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
val memberNameView by bind<TextView>(R.id.messageMemberNameView) val memberNameView by bind<TextView>(R.id.messageMemberNameView)
val timeView by bind<TextView>(R.id.messageTimeView) val timeView by bind<TextView>(R.id.messageTimeView)
val readReceiptsView by bind<ReadReceiptsView>(R.id.readReceiptsView) val readReceiptsView by bind<ReadReceiptsView>(R.id.readReceiptsView)
val readMarkerView by bind<ReadMarkerView>(R.id.readMarkerView)
var reactionWrapper: ViewGroup? = null var reactionWrapper: ViewGroup? = null
var reactionFlowHelper: Flow? = null var reactionFlowHelper: Flow? = null
} }

View File

@ -24,7 +24,10 @@ import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.platform.CheckableView import im.vector.riotx.core.platform.CheckableView
import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.utils.DimensionUtils.dpToPx import im.vector.riotx.core.utils.DimensionUtils.dpToPx
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController


/** /**
* Children must override getViewType() * Children must override getViewType()

View File

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


override fun unbind(holder: Holder) { override fun unbind(holder: Holder) {
contentUploadStateTrackerBinder.unbind(informationData.eventId) contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId)
super.unbind(holder) super.unbind(holder)
} }



View File

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





View File

@ -79,8 +79,8 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {


holder.messageView.setTextFuture(textFuture) holder.messageView.setTextFuture(textFuture)
renderSendState(holder.messageView, holder.messageView) renderSendState(holder.messageView, holder.messageView)
holder.messageView.setOnClickListener(cellClickListener) holder.messageView.setOnClickListener(attributes.itemClickListener)
holder.messageView.setOnLongClickListener(longClickListener) holder.messageView.setOnLongClickListener(attributes.itemLongClickListener)
findPillsAndProcess { it.bind(holder.messageView) } findPillsAndProcess { it.bind(holder.messageView) }
} }



View File

@ -22,6 +22,7 @@ import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.ui.views.ReadMarkerView
import im.vector.riotx.core.ui.views.ReadReceiptsView import im.vector.riotx.core.ui.views.ReadReceiptsView
import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
@ -53,6 +54,12 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
readReceiptsCallback?.onReadReceiptsClicked(informationData.readReceipts) readReceiptsCallback?.onReadReceiptsClicked(informationData.readReceipts)
}) })


private val _readMarkerCallback = object : ReadMarkerView.Callback {
override fun onReadMarkerDisplayed() {
readReceiptsCallback?.onReadMarkerLongDisplayed(informationData)
}
}

override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
holder.noticeTextView.text = noticeText holder.noticeTextView.text = noticeText
@ -60,11 +67,17 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
informationData.avatarUrl, informationData.avatarUrl,
informationData.senderId, informationData.senderId,
informationData.memberName?.toString() informationData.memberName?.toString()
?: informationData.senderId, ?: informationData.senderId,
holder.avatarImageView holder.avatarImageView
) )
holder.view.setOnLongClickListener(longClickListener) holder.view.setOnLongClickListener(longClickListener)
holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener) holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener)
holder.readMarkerView.bindView(informationData, _readMarkerCallback)
}

override fun unbind(holder: Holder) {
holder.readMarkerView.unbind()
super.unbind(holder)
} }


override fun getViewType() = STUB_ID override fun getViewType() = STUB_ID
@ -73,6 +86,7 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
val avatarImageView by bind<ImageView>(R.id.itemNoticeAvatarView) val avatarImageView by bind<ImageView>(R.id.itemNoticeAvatarView)
val noticeTextView by bind<TextView>(R.id.itemNoticeTextView) val noticeTextView by bind<TextView>(R.id.itemNoticeTextView)
val readReceiptsView by bind<ReadReceiptsView>(R.id.readReceiptsView) val readReceiptsView by bind<ReadReceiptsView>(R.id.readReceiptsView)
val readMarkerView by bind<ReadMarkerView>(R.id.readMarkerView)
} }


companion object { companion object {

View File

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


<alpha <alpha
android:duration="1500"
android:fromAlpha="1" android:fromAlpha="1"
android:toAlpha="0" /> android:toAlpha="0" />
</set> </set>

View File

@ -6,6 +6,29 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="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 <androidx.appcompat.widget.Toolbar
android:id="@+id/roomToolbar" android:id="@+id/roomToolbar"
style="@style/VectorToolbarStyle" style="@style/VectorToolbarStyle"
@ -71,28 +94,12 @@


</androidx.appcompat.widget.Toolbar> </androidx.appcompat.widget.Toolbar>


<!-- Trick to remove surrounding padding (clip frome wrapping frame) --> <androidx.constraintlayout.widget.Barrier
<FrameLayout android:id="@+id/recyclerViewBarrier"
android:id="@+id/syncProgressBarWrap" android:layout_width="wrap_content"
android:layout_width="match_parent" android:layout_height="wrap_content"
android:layout_height="3dp" app:barrierDirection="top"
android:visibility="gone" app:constraint_referenced_ids="composerLayout,notificationAreaView" />
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>


<com.airbnb.epoxy.EpoxyRecyclerView <com.airbnb.epoxy.EpoxyRecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
@ -105,22 +112,14 @@
app:layout_constraintTop_toBottomOf="@id/syncProgressBarWrap" app:layout_constraintTop_toBottomOf="@id/syncProgressBarWrap"
tools:listitem="@layout/item_timeline_event_base" /> tools:listitem="@layout/item_timeline_event_base" />


<androidx.constraintlayout.widget.Barrier <im.vector.riotx.core.ui.views.JumpToReadMarkerView
android:id="@+id/recyclerViewBarrier" android:id="@+id/jumpToReadMarkerView"
android:layout_width="wrap_content" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:barrierDirection="top" android:visibility="gone"
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"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/syncProgressBarWrap" />


<im.vector.riotx.core.ui.views.NotificationAreaView <im.vector.riotx.core.ui.views.NotificationAreaView
android:id="@+id/notificationAreaView" android:id="@+id/notificationAreaView"
@ -132,6 +131,16 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="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 <im.vector.riotx.features.invite.VectorInviteView
android:id="@+id/inviteView" android:id="@+id/inviteView"
android:layout_width="0dp" android:layout_width="0dp"
@ -144,5 +153,4 @@
app:layout_constraintTop_toBottomOf="@+id/roomToolbar" app:layout_constraintTop_toBottomOf="@+id/roomToolbar"
tools:visibility="visible" /> tools:visibility="visible" />



</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -122,15 +122,26 @@


</ViewStub> </ViewStub>



<im.vector.riotx.core.ui.views.ReadReceiptsView <im.vector.riotx.core.ui.views.ReadReceiptsView
android:id="@+id/readReceiptsView" android:id="@+id/readReceiptsView"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp" android:layout_marginBottom="4dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toTopOf="@+id/readMarkerView"
app:layout_constraintEnd_toEndOf="parent" /> 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"
app:layout_constraintStart_toStartOf="parent" />


</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -58,8 +58,21 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp" android:layout_marginBottom="4dp"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toTopOf="@+id/readMarkerView"
app:layout_constraintEnd_toEndOf="parent" /> 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"
app:layout_constraintStart_toStartOf="parent" />



</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -40,7 +40,7 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:background="?attr/colorAccent" android:background="?attr/riotx_header_panel_background"
app:layout_constraintEnd_toEndOf="@id/itemMergedExpandTextView" app:layout_constraintEnd_toEndOf="@id/itemMergedExpandTextView"
app:layout_constraintStart_toStartOf="@id/itemMergedAvatarListView" app:layout_constraintStart_toStartOf="@id/itemMergedAvatarListView"
app:layout_constraintTop_toBottomOf="@id/itemMergedExpandTextView" /> 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_marginStart="16dp"
android:layout_marginLeft="16dp"
android:layout_toStartOf="@+id/closeJumpToReadMarkerView"
android:layout_toLeftOf="@+id/closeJumpToReadMarkerView"
android:drawableStart="@drawable/jump_to_unread"
android:drawableLeft="@drawable/jump_to_unread"
android:drawablePadding="10dp"
android:gravity="center_vertical"
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: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>