Read markers: continue working on ui

This commit is contained in:
ganfra 2019-08-20 19:12:22 +02:00
parent d8f449388c
commit 51a4c93676
45 changed files with 1073 additions and 656 deletions

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

@ -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.
@ -60,6 +67,13 @@ interface Timeline {
fun failedToDeliverEventCount(): Int fun failedToDeliverEventCount(): Int
fun getIndexOfEvent(eventId: String?): Int?
fun getTimelineEventAtIndex(index: Int): TimelineEvent?
fun getTimelineEventWithId(eventId: String?): TimelineEvent?
interface Listener { interface Listener {
/** /**
* Call when the timeline has been updated through pagination or sync. * Call when the timeline has been updated through pagination or sync.

View file

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

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

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

@ -18,6 +18,7 @@ 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.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntity
@ -25,13 +26,17 @@ import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.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
@ -50,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) {
@ -74,12 +80,12 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI
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 {
@ -95,6 +101,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI
} }
} }
private fun isReadMarkerMoreRecent(roomId: String, fullyReadEventId: String): Boolean { private fun isReadMarkerMoreRecent(roomId: String, fullyReadEventId: String): Boolean {
return Realm.getInstance(monarchy.realmConfiguration).use { realm -> return Realm.getInstance(monarchy.realmConfiguration).use { realm ->
val readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId).findFirst() val readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId).findFirst()
@ -106,12 +113,18 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI
} }
} }
private fun updateNotificationCountIfNecessary(roomId: String, eventId: String) { private suspend fun updateReadMarker(roomId: String, eventId: String) {
monarchy.writeAsync { realm -> 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
} }

View file

@ -27,36 +27,15 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
import im.vector.matrix.android.api.util.CancelableBag import im.vector.matrix.android.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.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields
import im.vector.matrix.android.internal.database.query.FilterContent
import im.vector.matrix.android.internal.database.query.findAllInRoomWithSendStates
import im.vector.matrix.android.internal.database.query.findIncludingEvent
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.database.query.whereInRoom
import im.vector.matrix.android.internal.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.ObjectChangeSet import io.realm.*
import io.realm.OrderedCollectionChangeSet
import io.realm.OrderedRealmCollectionChangeListener
import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.RealmObjectChangeListener
import io.realm.RealmQuery
import io.realm.RealmResults
import io.realm.Sort
import 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
@ -70,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,
@ -78,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,11 +84,9 @@ internal class DefaultTimeline(
private lateinit var eventRelations: RealmResults<EventAnnotationsSummaryEntity> private lateinit var eventRelations: RealmResults<EventAnnotationsSummaryEntity>
private var roomEntity: RoomEntity? = null private var roomEntity: RoomEntity? = null
private var readMarkerEntity: ReadMarkerEntity? = null
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())
@ -116,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 ->
@ -124,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) {
@ -176,29 +154,6 @@ internal class DefaultTimeline(
if (hasChange) postSnapshot() if (hasChange) postSnapshot()
} }
private val readMarkerListener = RealmObjectChangeListener { readMarkerEntity: ReadMarkerEntity, _: ObjectChangeSet? ->
val isEventHidden = liveEvents.where().equalTo(TimelineEventEntityFields.EVENT_ID, readMarkerEntity.eventId).findFirst() == null
var hasChange = false
if (isEventHidden) {
val hiddenEvent = readMarkerEntity.timelineEvent?.firstOrNull() ?: return@RealmObjectChangeListener
val displayIndex = hiddenEvent.root?.displayIndex
if (displayIndex != null) {
// Then we are looking for the first displayable event after the hidden one
val firstDisplayedEvent = liveEvents.where()
.lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex)
.findFirst()
// If we find one, we should rebuild this one with marker
if (firstDisplayedEvent != null) {
hasChange = rebuildEvent(firstDisplayedEvent.eventId) {
it.copy(hasReadMarker = true)
}
}
}
}
if (hasChange) postSnapshot()
}
// Public methods ****************************************************************************** // Public methods ******************************************************************************
@ -254,14 +209,10 @@ internal class DefaultTimeline(
.findAllAsync() .findAllAsync()
.also { it.addChangeListener(relationsListener) } .also { it.addChangeListener(relationsListener) }
readMarkerEntity = ReadMarkerEntity.where(realm, roomId = roomId)
.findFirstAsync()
.also { it.addChangeListener(readMarkerListener) }
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)
} }
} }
@ -276,10 +227,11 @@ internal class DefaultTimeline(
roomEntity?.sendingTimelineEvents?.removeAllChangeListeners() roomEntity?.sendingTimelineEvents?.removeAllChangeListeners()
eventRelations.removeAllChangeListeners() eventRelations.removeAllChangeListeners()
liveEvents.removeAllChangeListeners() liveEvents.removeAllChangeListeners()
readMarkerEntity?.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()
} }
@ -287,6 +239,27 @@ internal class DefaultTimeline(
} }
} }
override fun restartWithEventId(eventId: String) {
dispose()
initialEventId = eventId
start()
postSnapshot()
}
override fun getIndexOfEvent(eventId: String?): Int? {
return builtEventsIdMap[eventId]
}
override fun getTimelineEventAtIndex(index: Int): TimelineEvent? {
return builtEvents.getOrNull(index)
}
override fun getTimelineEventWithId(eventId: String?): TimelineEvent? {
return builtEventsIdMap[eventId]?.let {
getTimelineEventAtIndex(it)
}
}
override fun hasMoreToLoad(direction: Timeline.Direction): Boolean { override fun hasMoreToLoad(direction: Timeline.Direction): Boolean {
return hasMoreInCache(direction) || !hasReachedEnd(direction) return hasMoreInCache(direction) || !hasReachedEnd(direction)
} }
@ -303,6 +276,18 @@ internal class DefaultTimeline(
postSnapshot() postSnapshot()
} }
// TimelineHiddenReadMarker.Delegate
override fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean {
return rebuildEvent(eventId) { te ->
te.copy(hasReadMarker = hasReadMarker)
}
}
override fun onReadMarkerUpdated() {
postSnapshot()
}
// Private methods ***************************************************************************** // Private methods *****************************************************************************
private fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent): Boolean { private fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent): Boolean {
@ -423,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) {
@ -571,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)
} }
} }
@ -594,10 +581,22 @@ internal class DefaultTimeline(
} }
private fun postSnapshot() { private fun postSnapshot() {
BACKGROUND_HANDLER.post {
val snapshot = createSnapshot() val snapshot = createSnapshot()
val runnable = Runnable { listener?.onUpdated(snapshot) } val runnable = Runnable { listener?.onUpdated(snapshot) }
debouncer.debounce("post_snapshot", runnable, 50) debouncer.debounce("post_snapshot", runnable, 50)
} }
}
private fun clearAllValues() {
prevDisplayIndex = DISPLAY_INDEX_UNKNOWN
nextDisplayIndex = DISPLAY_INDEX_UNKNOWN
builtEvents.clear()
builtEventsIdMap.clear()
backwardsPaginationState.set(PaginationState())
forwardsPaginationState.set(PaginationState())
}
// Extension methods *************************************************************************** // Extension methods ***************************************************************************

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

@ -18,6 +18,7 @@ package im.vector.matrix.android.internal.session.sync
import im.vector.matrix.android.api.session.room.read.FullyReadContent import im.vector.matrix.android.api.session.room.read.FullyReadContent
import im.vector.matrix.android.internal.database.model.ReadMarkerEntity import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.query.getOrCreate import im.vector.matrix.android.internal.database.query.getOrCreate
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
@ -32,9 +33,14 @@ internal class RoomFullyReadHandler @Inject constructor() {
return return
} }
Timber.v("Handle for roomId: $roomId eventId: ${content.eventId}") Timber.v("Handle for roomId: $roomId eventId: ${content.eventId}")
RoomSummaryEntity.getOrCreate(realm, roomId).apply {
readMarkerId = content.eventId
}
val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId).apply { val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId).apply {
eventId = content.eventId eventId = content.eventId
} }
// Remove the old marker if any // Remove the old marker if any
readMarkerEntity.timelineEvent?.firstOrNull()?.readMarker = null readMarkerEntity.timelineEvent?.firstOrNull()?.readMarker = null
// Attach to timelineEvent if known // Attach to timelineEvent if known

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,13 +26,16 @@ 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()
@ -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)
if (scrollPosition == null) {
scrollOnHighlightedEventCallback.scheduleScrollTo(it) 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()
@ -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,6 +890,14 @@ class RoomDetailFragment :
.show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS") .show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS")
} }
override fun onReadMarkerLongDisplayed(informationData: MessageInformationData) {
val firstVisibleItem = layoutManager.findFirstVisibleItemPosition()
val eventId = timelineEventController.searchEventIdAtPosition(firstVisibleItem)
if (eventId != null) {
roomDetailViewModel.process(RoomDetailActions.SetReadMarkerAction(eventId))
}
}
// AutocompleteUserPresenter.Callback // AutocompleteUserPresenter.Callback
override fun onQueryUsers(query: CharSequence?) { override fun onQueryUsers(query: CharSequence?) {
@ -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) }
@ -118,7 +123,8 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
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.TimelineEventTurnsInvisible -> handleEventInvisible(action)
is RoomDetailActions.LoadMoreTimelineEvents -> handleLoadMore(action) is RoomDetailActions.LoadMoreTimelineEvents -> handleLoadMore(action)
is RoomDetailActions.SendReaction -> handleSendReaction(action) is RoomDetailActions.SendReaction -> handleSendReaction(action)
is RoomDetailActions.AcceptInvite -> handleAcceptInvite() is RoomDetailActions.AcceptInvite -> handleAcceptInvite()
@ -136,10 +142,16 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is RoomDetailActions.RemoveFailedEcho -> handleRemove(action) is RoomDetailActions.RemoveFailedEcho -> handleRemove(action)
is RoomDetailActions.ClearSendQueue -> handleClearSendQueue() is RoomDetailActions.ClearSendQueue -> handleClearSendQueue()
is RoomDetailActions.ResendAll -> handleResendAll() is RoomDetailActions.ResendAll -> handleResendAll()
is RoomDetailActions.SetReadMarkerAction -> handleSetReadMarkerAction(action)
is RoomDetailActions.MarkAllAsRead -> handleMarkAllAsRead()
else -> Timber.e("Unhandled Action: $action") 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
)
} }
if (action.highlight) {
setState { copy(highlightedEventId = targetEventId) }
} }
setState {
copy(
eventId = targetEventId
)
}
}
_navigateToEvent.postLiveEvent(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)
}
} }
private fun handleResendEvent(action: RoomDetailActions.ResendMessage) { private fun handleResendEvent(action: RoomDetailActions.ResendMessage) {
@ -622,22 +591,36 @@ 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 ->
val readMarkerVisible = actions.find { it.event.hasReadMarker } != null
val mostRecentEvent = actions.maxBy { it.event.displayIndex } val mostRecentEvent = actions.maxBy { it.event.displayIndex }
mostRecentEvent?.event?.root?.eventId?.let { eventId -> mostRecentEvent?.event?.root?.eventId?.let { eventId ->
room.setReadReceipt(eventId, callback = object : MatrixCallback<Unit> {}) room.setReadReceipt(eventId, callback = object : MatrixCallback<Unit> {})
if (readMarkerVisible) {
room.setReadMarker(eventId, callback = object : MatrixCallback<Unit> {})
}
} }
}) })
.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()
@ -649,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,18 +326,42 @@ 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
modelCache.forEachIndexed { idx, cacheItemData -> var realPosition = 0
if (cacheItemData?.eventId == eventId) { for (i in 0 until modelCache.size) {
return idx val itemCache = modelCache[i]
if (itemCache?.eventId == eventId) {
return realPosition
}
if (itemCache?.eventModel != null) {
realPosition++
}
if (itemCache?.mergedHeaderModel != null) {
realPosition++
}
if (itemCache?.formattedDayModel != null) {
realPosition++
} }
} }
return null return null
} }
fun searchEventIdAtPosition(position: Int): String? = synchronized(modelCache) {
var offsetValue = 0
for (i in 0 until position) {
val itemCache = modelCache[i]
if (itemCache?.eventModel == null) {
offsetValue--
} }
if (itemCache?.mergedHeaderModel != null) {
offsetValue++
}
if (itemCache?.formattedDayModel != null) {
offsetValue++
}
}
return modelCache.getOrNull(position - offsetValue)?.eventId
} }
private data class CacheItemData( private data class CacheItemData(
@ -348,3 +371,5 @@ private data class CacheItemData(
val mergedHeaderModel: MergedHeaderItem? = null, val mergedHeaderModel: MergedHeaderItem? = null,
val formattedDayModel: DaySeparatorItem? = 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,7 +84,8 @@ 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 =
@ -112,22 +99,26 @@ class MessageItemFactory @Inject constructor(
// 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,36 +181,23 @@ 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(
@ -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)
.readReceiptsCallback(callback)
.emojiTypeFace(emojiCompatFontProvider.typeface)
//click on the text //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,
@ -365,7 +291,8 @@ class MessageItemFactory @Inject constructor(
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")
@ -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,4 +1,5 @@
/* /*
* Copyright 2019 New Vector Ltd * Copyright 2019 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
@ -12,9 +13,10 @@
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * 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,7 +64,8 @@ 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 val displayReadMarker = event.hasReadMarker
&& event.readReceipts.find { it.user.userId == session.myUserId } == null
return MessageInformationData( return MessageInformationData(
eventId = eventId, eventId = eventId,

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,11 +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.readMarkerView.isVisible = informationData.displayReadMarker if (!shouldShowReactionAtBottom() || attributes.informationData.orderedReactionList.isNullOrEmpty()) {
holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener)
if (!shouldShowReactionAtBottom() || informationData.orderedReactionList.isNullOrEmpty()) {
holder.reactionWrapper?.isVisible = false holder.reactionWrapper?.isVisible = false
} else { } else {
//inflate if needed //inflate if needed
@ -144,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
@ -152,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
} }
@ -163,27 +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<View>(R.id.readMarkerView) 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

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

@ -19,10 +19,10 @@ package im.vector.riotx.features.home.room.detail.timeline.item
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.core.view.isVisible
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
@ -54,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
@ -66,7 +72,12 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
) )
holder.view.setOnLongClickListener(longClickListener) holder.view.setOnLongClickListener(longClickListener)
holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener) holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener)
holder.readMarkerView.isVisible = informationData.displayReadMarker holder.readMarkerView.bindView(informationData, _readMarkerCallback)
}
override fun unbind(holder: Holder) {
holder.readMarkerView.unbind()
super.unbind(holder)
} }
override fun getViewType() = STUB_ID override fun getViewType() = STUB_ID
@ -75,7 +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<View>(R.id.readMarkerView) 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

@ -128,17 +128,20 @@
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" />
<View <im.vector.riotx.core.ui.views.ReadMarkerView
android:id="@+id/readMarkerView" android:id="@+id/readMarkerView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="4dp" android:layout_height="2dp"
android:background="@android:color/holo_green_light" 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_constraintEnd_toEndOf="parent"
android:visibility="gone" app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -58,18 +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" />
<View <im.vector.riotx.core.ui.views.ReadMarkerView
android:id="@+id/readMarkerView" android:id="@+id/readMarkerView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="4dp" android:layout_height="2dp"
android:background="@android:color/holo_green_light" 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_constraintEnd_toEndOf="parent"
android:visibility="gone" app:layout_constraintStart_toStartOf="parent" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="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>