Merge pull request #699 from vector-im/feature/read_marker_rework

Feature/read marker rework
This commit is contained in:
Benoit Marty 2019-12-04 14:12:41 +01:00 committed by GitHub
commit 2717ad475a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 458 additions and 771 deletions

View file

@ -8,6 +8,7 @@ Features ✨:
Improvements 🙌:
- Send mention Pills from composer
- Links in message preview in the bottom sheet are now active.
- Rework the read marker to make it more usable
Other changes:
- Fix a small grammatical error when an empty room list is shown.

View file

@ -30,10 +30,16 @@ package im.vector.matrix.android.api.session.room.timeline
*/
interface Timeline {
var listener: Listener?
val timelineID: String
val isLive: Boolean
fun addListener(listener: Listener): Boolean
fun removeListener(listener: Listener): Boolean
fun removeAllListeners()
/**
* This should be called before any other method after creating the timeline. It ensures the underlying database is open
*/
@ -98,7 +104,7 @@ interface Timeline {
interface Listener {
/**
* Call when the timeline has been updated through pagination or sync.
* @param snapshot the most uptodate snapshot
* @param snapshot the most up to date snapshot
*/
fun onUpdated(snapshot: List<TimelineEvent>)
}

View file

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

View file

@ -23,7 +23,6 @@ import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.mapper.toEntity
import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity
import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
@ -140,7 +139,7 @@ internal fun ChunkEntity.add(roomId: String,
val senderId = event.senderId ?: ""
val readReceiptsSummaryEntity = ReadReceiptsSummaryEntity.where(realm, eventId).findFirst()
?: ReadReceiptsSummaryEntity(eventId, roomId)
?: ReadReceiptsSummaryEntity(eventId, roomId)
// Update RR for the sender of a new message with a dummy one
@ -168,7 +167,6 @@ internal fun ChunkEntity.add(roomId: String,
it.roomId = roomId
it.annotations = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
it.readReceipts = readReceiptsSummaryEntity
it.readMarker = ReadMarkerEntity.where(realm, roomId = roomId, eventId = eventId).findFirst()
}
val position = if (direction == PaginationDirection.FORWARDS) 0 else this.timelineEvents.size
timelineEvents.add(position, eventEntity)
@ -176,14 +174,14 @@ internal fun ChunkEntity.add(roomId: String,
internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) {
PaginationDirection.FORWARDS -> forwardsDisplayIndex
PaginationDirection.BACKWARDS -> backwardsDisplayIndex
} ?: defaultValue
PaginationDirection.FORWARDS -> forwardsDisplayIndex
PaginationDirection.BACKWARDS -> backwardsDisplayIndex
} ?: defaultValue
}
internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) {
PaginationDirection.FORWARDS -> forwardsStateIndex
PaginationDirection.BACKWARDS -> backwardsStateIndex
} ?: defaultValue
PaginationDirection.FORWARDS -> forwardsStateIndex
PaginationDirection.BACKWARDS -> backwardsStateIndex
} ?: defaultValue
}

View file

@ -36,7 +36,7 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS
}
return TimelineEvent(
root = timelineEventEntity.root?.asDomain()
?: Event("", timelineEventEntity.eventId),
?: Event("", timelineEventEntity.eventId),
annotations = timelineEventEntity.annotations?.asDomain(),
localId = timelineEventEntity.localId,
displayIndex = timelineEventEntity.root?.displayIndex ?: 0,
@ -45,8 +45,7 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS
senderAvatar = timelineEventEntity.senderAvatar,
readReceipts = readReceipts?.sortedByDescending {
it.originServerTs
} ?: emptyList(),
hasReadMarker = timelineEventEntity.readMarker?.eventId?.isNotEmpty() == true
} ?: emptyList()
)
}
}

View file

@ -17,8 +17,6 @@
package im.vector.matrix.android.internal.database.model
import io.realm.RealmObject
import io.realm.RealmResults
import io.realm.annotations.LinkingObjects
import io.realm.annotations.PrimaryKey
internal open class ReadMarkerEntity(
@ -27,8 +25,5 @@ internal open class ReadMarkerEntity(
var eventId: String = ""
) : RealmObject() {
@LinkingObjects("readMarker")
val timelineEvent: RealmResults<TimelineEventEntity>? = null
companion object
}

View file

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

View file

@ -22,13 +22,9 @@ import io.realm.Realm
import io.realm.RealmQuery
import io.realm.kotlin.where
internal fun ReadMarkerEntity.Companion.where(realm: Realm, roomId: String, eventId: String? = null): RealmQuery<ReadMarkerEntity> {
val query = realm.where<ReadMarkerEntity>()
internal fun ReadMarkerEntity.Companion.where(realm: Realm, roomId: String): RealmQuery<ReadMarkerEntity> {
return realm.where<ReadMarkerEntity>()
.equalTo(ReadMarkerEntityFields.ROOM_ID, roomId)
if (eventId != null) {
query.equalTo(ReadMarkerEntityFields.EVENT_ID, eventId)
}
return query
}
internal fun ReadMarkerEntity.Companion.getOrCreate(realm: Realm, roomId: String): ReadMarkerEntity {

View file

@ -18,7 +18,9 @@ package im.vector.matrix.android.internal.database.query
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.events.model.LocalEcho
import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptEntity
import io.realm.Realm
internal fun isEventRead(monarchy: Monarchy,
userId: String?,
@ -39,8 +41,10 @@ internal fun isEventRead(monarchy: Monarchy,
isEventRead = if (eventToCheck?.sender == userId) {
true
} else {
val readReceipt = ReadReceiptEntity.where(realm, roomId, userId).findFirst() ?: return@doWithRealm
val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex ?: Int.MIN_VALUE
val readReceipt = ReadReceiptEntity.where(realm, roomId, userId).findFirst()
?: return@doWithRealm
val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex
?: Int.MIN_VALUE
val eventToCheckIndex = eventToCheck?.displayIndex ?: Int.MAX_VALUE
eventToCheckIndex <= readReceiptIndex
@ -49,3 +53,21 @@ internal fun isEventRead(monarchy: Monarchy,
return isEventRead
}
internal fun isReadMarkerMoreRecent(monarchy: Monarchy,
roomId: String?,
eventId: String?): Boolean {
if (roomId.isNullOrBlank() || eventId.isNullOrBlank()) {
return false
}
return Realm.getInstance(monarchy.realmConfiguration).use { realm ->
val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) ?: return false
val eventToCheck = liveChunk.timelineEvents.find(eventId)?.root
val readMarker = ReadMarkerEntity.where(realm, roomId).findFirst() ?: return false
val readMarkerIndex = liveChunk.timelineEvents.find(readMarker.eventId)?.root?.displayIndex
?: Int.MIN_VALUE
val eventToCheckIndex = eventToCheck?.displayIndex ?: Int.MAX_VALUE
eventToCheckIndex <= readMarkerIndex
}
}

View file

@ -18,7 +18,6 @@ package im.vector.matrix.android.internal.session.room.read
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.events.model.LocalEcho
import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.query.*
@ -57,22 +56,18 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI
override suspend fun execute(params: SetReadMarkersTask.Params) {
val markers = HashMap<String, String>()
val fullyReadEventId: String?
val readReceiptEventId: String?
Timber.v("Execute set read marker with params: $params")
if (params.markAllAsRead) {
val (fullyReadEventId, readReceiptEventId) = if (params.markAllAsRead) {
val latestSyncedEventId = Realm.getInstance(monarchy.realmConfiguration).use { realm ->
TimelineEventEntity.latestEvent(realm, roomId = params.roomId, includesSending = false)?.eventId
}
fullyReadEventId = latestSyncedEventId
readReceiptEventId = latestSyncedEventId
Pair(latestSyncedEventId, latestSyncedEventId)
} else {
fullyReadEventId = params.fullyReadEventId
readReceiptEventId = params.readReceiptEventId
Pair(params.fullyReadEventId, params.readReceiptEventId)
}
if (fullyReadEventId != null && isReadMarkerMoreRecent(params.roomId, fullyReadEventId)) {
if (fullyReadEventId != null && !isReadMarkerMoreRecent(monarchy, params.roomId, fullyReadEventId)) {
if (LocalEcho.isLocalEchoId(fullyReadEventId)) {
Timber.w("Can't set read marker for local event $fullyReadEventId")
} else {
@ -118,16 +113,4 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI
}
}
}
private fun isReadMarkerMoreRecent(roomId: String, newReadMarkerId: String): Boolean {
return Realm.getInstance(monarchy.realmConfiguration).use { realm ->
val currentReadMarkerId = ReadMarkerEntity.where(realm, roomId = roomId).findFirst()?.eventId
?: return true
val readMarkerEvent = TimelineEventEntity.where(realm, roomId = roomId, eventId = currentReadMarkerId).findFirst()
val newReadMarkerEvent = TimelineEventEntity.where(realm, roomId = roomId, eventId = newReadMarkerId).findFirst()
val currentReadMarkerIndex = readMarkerEvent?.root?.displayIndex ?: Int.MAX_VALUE
val newReadMarkerIndex = newReadMarkerEvent?.root?.displayIndex ?: Int.MIN_VALUE
newReadMarkerIndex > currentReadMarkerIndex
}
}
}

View file

@ -26,7 +26,8 @@ internal interface GetContextOfEventTask : Task<GetContextOfEventTask.Params, To
data class Params(
val roomId: String,
val eventId: String
val eventId: String,
val limit: Int
)
}
@ -38,7 +39,7 @@ internal class DefaultGetContextOfEventTask @Inject constructor(private val room
override suspend fun execute(params: GetContextOfEventTask.Params): TokenChunkEventPersistor.Result {
val filter = filterRepository.getRoomFilter()
val response = executeRequest<EventContextResponse> {
apiCall = roomAPI.getContextOfEvent(params.roomId, params.eventId, 0, filter)
apiCall = roomAPI.getContextOfEvent(params.roomId, params.eventId, params.limit, filter)
}
return tokenChunkEventPersistor.insertInDb(response, params.roomId, PaginationDirection.BACKWARDS)
}

View file

@ -74,22 +74,14 @@ internal class DefaultTimeline(
private val cryptoService: CryptoService,
private val timelineEventMapper: TimelineEventMapper,
private val settings: TimelineSettings,
private val hiddenReadReceipts: TimelineHiddenReadReceipts,
private val hiddenReadMarker: TimelineHiddenReadMarker
) : Timeline, TimelineHiddenReadReceipts.Delegate, TimelineHiddenReadMarker.Delegate {
private val hiddenReadReceipts: TimelineHiddenReadReceipts
) : Timeline, TimelineHiddenReadReceipts.Delegate {
private companion object {
val BACKGROUND_HANDLER = createBackgroundHandler("TIMELINE_DB_THREAD")
}
override var listener: Timeline.Listener? = null
set(value) {
field = value
BACKGROUND_HANDLER.post {
postSnapshot()
}
}
private val listeners = ArrayList<Timeline.Listener>()
private val isStarted = AtomicBoolean(false)
private val isReady = AtomicBoolean(false)
private val mainHandler = createUIHandler()
@ -110,7 +102,7 @@ internal class DefaultTimeline(
private val backwardsState = AtomicReference(State())
private val forwardsState = AtomicReference(State())
private val timelineID = UUID.randomUUID().toString()
override val timelineID = UUID.randomUUID().toString()
override val isLive
get() = !hasMoreToLoad(Timeline.Direction.FORWARDS)
@ -197,7 +189,6 @@ internal class DefaultTimeline(
if (settings.buildReadReceipts) {
hiddenReadReceipts.start(realm, filteredEvents, nonFilteredEvents, this)
}
hiddenReadMarker.start(realm, filteredEvents, nonFilteredEvents, this)
isReady.set(true)
}
}
@ -217,7 +208,6 @@ internal class DefaultTimeline(
if (this::filteredEvents.isInitialized) {
filteredEvents.removeAllChangeListeners()
}
hiddenReadMarker.dispose()
if (settings.buildReadReceipts) {
hiddenReadReceipts.dispose()
}
@ -298,7 +288,21 @@ internal class DefaultTimeline(
return hasMoreInCache(direction) || !hasReachedEnd(direction)
}
// TimelineHiddenReadReceipts.Delegate
override fun addListener(listener: Timeline.Listener) = synchronized(listeners) {
listeners.add(listener).also {
postSnapshot()
}
}
override fun removeListener(listener: Timeline.Listener) = synchronized(listeners) {
listeners.remove(listener)
}
override fun removeAllListeners() = synchronized(listeners) {
listeners.clear()
}
// TimelineHiddenReadReceipts.Delegate
override fun rebuildEvent(eventId: String, readReceipts: List<ReadReceipt>): Boolean {
return rebuildEvent(eventId) { te ->
@ -310,19 +314,7 @@ internal class DefaultTimeline(
postSnapshot()
}
// TimelineHiddenReadMarker.Delegate
override fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean {
return rebuildEvent(eventId) { te ->
te.copy(hasReadMarker = hasReadMarker)
}
}
override fun onReadMarkerUpdated() {
postSnapshot()
}
// Private methods *****************************************************************************
// Private methods *****************************************************************************
private fun rebuildEvent(eventId: String, builder: (TimelineEvent) -> TimelineEvent): Boolean {
return builtEventsIdMap[eventId]?.let { builtIndex ->
@ -502,9 +494,9 @@ internal class DefaultTimeline(
return
}
val params = PaginationTask.Params(roomId = roomId,
from = token,
direction = direction.toPaginationDirection(),
limit = limit)
from = token,
direction = direction.toPaginationDirection(),
limit = limit)
Timber.v("Should fetch $limit items $direction")
cancelableBag += paginationTask
@ -579,7 +571,7 @@ internal class DefaultTimeline(
val timelineEvent = buildTimelineEvent(eventEntity)
if (timelineEvent.isEncrypted()
&& timelineEvent.root.mxDecryptionResult == null) {
&& timelineEvent.root.mxDecryptionResult == null) {
timelineEvent.root.eventId?.let { eventDecryptor.requestDecryption(it) }
}
@ -641,7 +633,7 @@ internal class DefaultTimeline(
}
private fun fetchEvent(eventId: String) {
val params = GetContextOfEventTask.Params(roomId, eventId)
val params = GetContextOfEventTask.Params(roomId, eventId, settings.initialSize)
cancelableBag += contextOfEventTask.configureWith(params).executeBy(taskExecutor)
}
@ -652,7 +644,13 @@ internal class DefaultTimeline(
}
updateLoadingStates(filteredEvents)
val snapshot = createSnapshot()
val runnable = Runnable { listener?.onUpdated(snapshot) }
val runnable = Runnable {
synchronized(listeners) {
listeners.forEach {
it.onUpdated(snapshot)
}
}
}
debouncer.debounce("post_snapshot", runnable, 50)
}
}

View file

@ -53,17 +53,16 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv
override fun createTimeline(eventId: String?, settings: TimelineSettings): Timeline {
return DefaultTimeline(roomId,
eventId,
monarchy.realmConfiguration,
taskExecutor,
contextOfEventTask,
clearUnlinkedEventsTask,
paginationTask,
cryptoService,
timelineEventMapper,
settings,
TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings),
TimelineHiddenReadMarker(roomId, settings)
eventId,
monarchy.realmConfiguration,
taskExecutor,
contextOfEventTask,
clearUnlinkedEventsTask,
paginationTask,
cryptoService,
timelineEventMapper,
settings,
TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings)
)
}

View file

@ -30,6 +30,7 @@ data class EventContextResponse(
@Json(name = "state") override val stateEvents: List<Event> = emptyList()
) : TokenChunkEvent {
override val events: List<Event>
get() = listOf(event)
override val events: List<Event> by lazy {
eventsAfter.reversed() + listOf(event) + eventsBefore
}
}

View file

@ -1,133 +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.matrix.android.internal.session.room.timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.ReadMarkerEntityFields
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields
import im.vector.matrix.android.internal.database.query.FilterContent
import im.vector.matrix.android.internal.database.query.where
import io.realm.OrderedRealmCollectionChangeListener
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.RealmResults
/**
* This class is responsible for handling the read marker for hidden events.
* When an hidden event has read marker, we want to transfer it on the first older displayed event.
* It has to be used in [DefaultTimeline] and we should call the [start] and [dispose] methods to properly handle realm subscription.
*/
internal class TimelineHiddenReadMarker constructor(private val roomId: String,
private val settings: TimelineSettings) {
interface Delegate {
fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean
fun onReadMarkerUpdated()
}
private var previousDisplayedEventId: String? = null
private var hiddenReadMarker: RealmResults<ReadMarkerEntity>? = null
private lateinit var filteredEvents: RealmResults<TimelineEventEntity>
private lateinit var nonFilteredEvents: RealmResults<TimelineEventEntity>
private lateinit var delegate: Delegate
private val readMarkerListener = OrderedRealmCollectionChangeListener<RealmResults<ReadMarkerEntity>> { readMarkers, changeSet ->
if (!readMarkers.isLoaded || !readMarkers.isValid) {
return@OrderedRealmCollectionChangeListener
}
var hasChange = false
if (changeSet.deletions.isNotEmpty()) {
previousDisplayedEventId?.also {
hasChange = delegate.rebuildEvent(it, false)
previousDisplayedEventId = null
}
}
val readMarker = readMarkers.firstOrNull() ?: return@OrderedRealmCollectionChangeListener
val hiddenEvent = readMarker.timelineEvent?.firstOrNull()
?: return@OrderedRealmCollectionChangeListener
val isLoaded = nonFilteredEvents.where()
.equalTo(TimelineEventEntityFields.EVENT_ID, hiddenEvent.eventId)
.findFirst() != null
val displayIndex = hiddenEvent.root?.displayIndex
if (isLoaded && displayIndex != null) {
// Then we are looking for the first displayable event after the hidden one
val firstDisplayedEvent = filteredEvents.where()
.lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex)
.findFirst()
// If we find one, we should rebuild this one with marker
if (firstDisplayedEvent != null) {
previousDisplayedEventId = firstDisplayedEvent.eventId
hasChange = delegate.rebuildEvent(firstDisplayedEvent.eventId, true)
}
}
if (hasChange) {
delegate.onReadMarkerUpdated()
}
}
/**
* Start the realm query subscription. Has to be called on an HandlerThread
*/
fun start(realm: Realm,
filteredEvents: RealmResults<TimelineEventEntity>,
nonFilteredEvents: RealmResults<TimelineEventEntity>,
delegate: Delegate) {
this.filteredEvents = filteredEvents
this.nonFilteredEvents = nonFilteredEvents
this.delegate = delegate
// We are looking for read receipts set on hidden events.
// We only accept those with a timelineEvent (so coming from pagination/sync).
hiddenReadMarker = ReadMarkerEntity.where(realm, roomId = roomId)
.isNotEmpty(ReadMarkerEntityFields.TIMELINE_EVENT)
.filterReceiptsWithSettings()
.findAllAsync()
.also { it.addChangeListener(readMarkerListener) }
}
/**
* Dispose the realm query subscription. Has to be called on an HandlerThread
*/
fun dispose() {
this.hiddenReadMarker?.removeAllChangeListeners()
}
/**
* We are looking for readMarker related to filtered events. So, it's the opposite of [DefaultTimeline.filterEventsWithSettings] method.
*/
private fun RealmQuery<ReadMarkerEntity>.filterReceiptsWithSettings(): RealmQuery<ReadMarkerEntity> {
beginGroup()
if (settings.filterTypes) {
not().`in`("${ReadMarkerEntityFields.TIMELINE_EVENT}.${TimelineEventEntityFields.ROOT.TYPE}", settings.allowedTypes.toTypedArray())
}
if (settings.filterTypes && settings.filterEdits) {
or()
}
if (settings.filterEdits) {
like("${ReadMarkerEntityFields.TIMELINE_EVENT}.${TimelineEventEntityFields.ROOT.CONTENT}", FilterContent.EDIT_TYPE)
}
endGroup()
return this
}
}

View file

@ -16,14 +16,10 @@
package im.vector.matrix.android.internal.session.sync
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.session.room.read.FullyReadContent
import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields
import im.vector.matrix.android.internal.database.query.getOrCreate
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.room.read.FullyReadContent
import io.realm.Realm
import timber.log.Timber
import javax.inject.Inject
@ -39,18 +35,8 @@ internal class RoomFullyReadHandler @Inject constructor() {
RoomSummaryEntity.getOrCreate(realm, roomId).apply {
readMarkerId = content.eventId
}
// Remove the old markers if any
val oldReadMarkerEvents = TimelineEventEntity
.where(realm, roomId = roomId, linkFilterMode = EventEntity.LinkFilterMode.BOTH)
.isNotNull(TimelineEventEntityFields.READ_MARKER.`$`)
.findAll()
oldReadMarkerEvents.forEach { it.readMarker = null }
val readMarkerEntity = ReadMarkerEntity.getOrCreate(realm, roomId).apply {
ReadMarkerEntity.getOrCreate(realm, roomId).apply {
this.eventId = content.eventId
}
// Attach to timelineEvent if known
val timelineEventEntities = TimelineEventEntity.where(realm, roomId = roomId, eventId = content.eventId).findAll()
timelineEventEntities.forEach { it.readMarker = readMarkerEntity }
}
}

View file

@ -24,7 +24,3 @@ fun TimelineEvent.canReact(): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
return root.getClearType() == EventType.MESSAGE && root.sendState == SendState.SYNCED && !root.isRedacted()
}
fun TimelineEvent.displayReadMarker(myUserId: String): Boolean {
return hasReadMarker && readReceipts.find { it.user.userId == myUserId } == null
}

View file

@ -23,7 +23,6 @@ import android.util.AttributeSet
import android.view.View
import android.widget.RelativeLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isInvisible
import im.vector.riotx.R
import kotlinx.android.synthetic.main.view_jump_to_read_marker.view.*
@ -34,7 +33,7 @@ class JumpToReadMarkerView @JvmOverloads constructor(
) : RelativeLayout(context, attrs, defStyleAttr) {
interface Callback {
fun onJumpToReadMarkerClicked(readMarkerId: String)
fun onJumpToReadMarkerClicked()
fun onClearReadMarkerClicked()
}
@ -44,24 +43,15 @@ class JumpToReadMarkerView @JvmOverloads constructor(
setupView()
}
private var readMarkerId: String? = null
private fun setupView() {
inflate(context, R.layout.view_jump_to_read_marker, this)
setBackgroundColor(ContextCompat.getColor(context, R.color.notification_accent_color))
jumpToReadMarkerLabelView.setOnClickListener {
readMarkerId?.also {
callback?.onJumpToReadMarkerClicked(it)
}
callback?.onJumpToReadMarkerClicked()
}
closeJumpToReadMarkerView.setOnClickListener {
visibility = View.INVISIBLE
callback?.onClearReadMarkerClicked()
}
}
fun render(show: Boolean, readMarkerId: String?) {
this.readMarkerId = readMarkerId
isInvisible = !show
}
}

View file

@ -1,89 +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.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 kotlinx.coroutines.*
private const val DELAY_IN_MS = 1_000L
class ReadMarkerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
interface Callback {
fun onReadMarkerLongBound(isDisplayed: Boolean)
}
private var eventId: String? = null
private var callback: Callback? = null
private var callbackDispatcherJob: Job? = null
fun bindView(eventId: String?, hasReadMarker: Boolean, displayReadMarker: Boolean, readMarkerCallback: Callback) {
this.eventId = eventId
this.callback = readMarkerCallback
if (displayReadMarker) {
startAnimation()
} else {
this.animation?.cancel()
this.visibility = INVISIBLE
}
if (hasReadMarker) {
callbackDispatcherJob = GlobalScope.launch(Dispatchers.Main) {
delay(DELAY_IN_MS)
callback?.onReadMarkerLongBound(displayReadMarker)
}
}
}
fun unbind() {
this.callbackDispatcherJob?.cancel()
this.callback = null
this.eventId = null
this.animation?.cancel()
this.visibility = INVISIBLE
}
private fun startAnimation() {
if (animation == null) {
animation = AnimationUtils.loadAnimation(context, R.anim.unread_marker_anim)
animation.startOffset = DELAY_IN_MS / 2
animation.duration = DELAY_IN_MS / 2
animation.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
}
override fun onAnimationEnd(animation: Animation) {
visibility = INVISIBLE
}
override fun onAnimationRepeat(animation: Animation) {}
})
}
visibility = VISIBLE
animation.start()
}
}

View file

@ -1,99 +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
import androidx.recyclerview.widget.LinearLayoutManager
import im.vector.riotx.core.di.ScreenScope
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import javax.inject.Inject
@ScreenScope
class ReadMarkerHelper @Inject constructor() {
lateinit var timelineEventController: TimelineEventController
lateinit var layoutManager: LinearLayoutManager
var callback: Callback? = null
private var onReadMarkerLongDisplayed = false
private var jumpToReadMarkerVisible = false
private var readMarkerVisible: Boolean = true
private var state: RoomDetailViewState? = null
fun readMarkerVisible(): Boolean {
return readMarkerVisible
}
fun onResume() {
onReadMarkerLongDisplayed = false
}
fun onReadMarkerLongDisplayed() {
onReadMarkerLongDisplayed = true
}
fun updateWith(newState: RoomDetailViewState) {
state = newState
checkReadMarkerVisibility()
checkJumpToReadMarkerVisibility()
}
fun onTimelineScrolled() {
checkJumpToReadMarkerVisibility()
}
private fun checkReadMarkerVisibility() {
val nonNullState = this.state ?: return
val firstVisibleItem = layoutManager.findFirstVisibleItemPosition()
val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
readMarkerVisible = if (!onReadMarkerLongDisplayed) {
true
} else {
if (nonNullState.timeline?.isLive == false) {
true
} else {
!(firstVisibleItem == 0 && lastVisibleItem > 0)
}
}
}
private fun checkJumpToReadMarkerVisibility() {
val nonNullState = this.state ?: return
val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
val readMarkerId = nonNullState.asyncRoomSummary()?.readMarkerId
val newJumpToReadMarkerVisible = if (readMarkerId == null) {
false
} else {
val correctedReadMarkerId = nonNullState.timeline?.getFirstDisplayableEventId(readMarkerId)
?: readMarkerId
val positionOfReadMarker = timelineEventController.searchPositionOfEvent(correctedReadMarkerId)
if (positionOfReadMarker == null) {
nonNullState.timeline?.isLive == true && lastVisibleItem > 0
} else {
positionOfReadMarker > lastVisibleItem
}
}
if (newJumpToReadMarkerVisible != jumpToReadMarkerVisible) {
jumpToReadMarkerVisible = newJumpToReadMarkerVisible
callback?.onJumpToReadMarkerVisibilityUpdate(jumpToReadMarkerVisible, readMarkerId)
}
}
interface Callback {
fun onJumpToReadMarkerVisibilityUpdate(show: Boolean, readMarkerId: String?)
}
}

View file

@ -35,13 +35,15 @@ sealed class RoomDetailAction : VectorViewModelAction {
data class RedactAction(val targetEventId: String, val reason: String? = "") : RoomDetailAction()
data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailAction()
data class NavigateToEvent(val eventId: String, val highlight: Boolean) : RoomDetailAction()
data class SetReadMarkerAction(val eventId: String) : RoomDetailAction()
object MarkAllAsRead : RoomDetailAction()
data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailAction()
data class HandleTombstoneEvent(val event: Event) : RoomDetailAction()
object AcceptInvite : RoomDetailAction()
object RejectInvite : RoomDetailAction()
object EnterTrackingUnreadMessagesState : RoomDetailAction()
object ExitTrackingUnreadMessagesState : RoomDetailAction()
data class EnterEditMode(val eventId: String, val text: String) : RoomDetailAction()
data class EnterQuoteMode(val eventId: String, val text: String) : RoomDetailAction()
data class EnterReplyMode(val eventId: String, val text: String) : RoomDetailAction()

View file

@ -39,6 +39,7 @@ import androidx.core.text.buildSpannedString
import androidx.core.util.Pair
import androidx.core.view.ViewCompat
import androidx.core.view.forEach
import androidx.core.view.isVisible
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
@ -57,7 +58,6 @@ import im.vector.matrix.android.api.permalinks.PermalinkFactory
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.LocalEcho
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.send.SendState
@ -145,8 +145,7 @@ class RoomDetailFragment @Inject constructor(
val textComposerViewModelFactory: TextComposerViewModel.Factory,
private val errorFormatter: ErrorFormatter,
private val eventHtmlRenderer: EventHtmlRenderer,
private val vectorPreferences: VectorPreferences,
private val readMarkerHelper: ReadMarkerHelper
private val vectorPreferences: VectorPreferences
) :
VectorBaseFragment(),
TimelineEventController.Callback,
@ -292,6 +291,7 @@ class RoomDetailFragment @Inject constructor(
}
override fun onDestroy() {
roomDetailViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState)
debouncer.cancelAll()
super.onDestroy()
}
@ -299,6 +299,7 @@ class RoomDetailFragment @Inject constructor(
private fun setupJumpToBottomView() {
jumpToBottomView.visibility = View.INVISIBLE
jumpToBottomView.setOnClickListener {
roomDetailViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState)
jumpToBottomView.visibility = View.INVISIBLE
withState(roomDetailViewModel) { state ->
if (state.timeline?.isLive == false) {
@ -423,12 +424,12 @@ class RoomDetailFragment @Inject constructor(
if (text != composerLayout.composerEditText.text.toString()) {
// Ignore update to avoid saving a draft
composerLayout.composerEditText.setText(text)
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text?.length ?: 0)
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text?.length
?: 0)
}
}
override fun onResume() {
readMarkerHelper.onResume()
super.onResume()
notificationDrawerManager.setCurrentRoom(roomDetailArgs.roomId)
}
@ -473,24 +474,12 @@ class RoomDetailFragment @Inject constructor(
it.dispatchTo(stateRestorer)
it.dispatchTo(scrollOnNewMessageCallback)
it.dispatchTo(scrollOnHighlightedEventCallback)
}
readMarkerHelper.timelineEventController = timelineEventController
readMarkerHelper.layoutManager = layoutManager
readMarkerHelper.callback = object : ReadMarkerHelper.Callback {
override fun onJumpToReadMarkerVisibilityUpdate(show: Boolean, readMarkerId: String?) {
jumpToReadMarkerView.render(show, readMarkerId)
}
updateJumpToReadMarkerViewVisibility()
updateJumpToBottomViewVisibility()
}
recyclerView.adapter = timelineEventController.adapter
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
updateJumpToBottomViewVisibility()
}
readMarkerHelper.onTimelineScrolled()
}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
when (newState) {
RecyclerView.SCROLL_STATE_IDLE -> {
@ -532,6 +521,30 @@ class RoomDetailFragment @Inject constructor(
}
}
private fun updateJumpToReadMarkerViewVisibility() = jumpToReadMarkerView.post {
withState(roomDetailViewModel) {
val showJumpToUnreadBanner = when (it.unreadState) {
UnreadState.Unknown,
UnreadState.HasNoUnread -> false
is UnreadState.ReadMarkerNotLoaded -> true
is UnreadState.HasUnread -> {
if (it.canShowJumpToReadMarker) {
val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
val positionOfReadMarker = timelineEventController.getPositionOfReadMarker()
if (positionOfReadMarker == null) {
false
} else {
positionOfReadMarker > lastVisibleItem
}
} else {
false
}
}
}
jumpToReadMarkerView.isVisible = showJumpToUnreadBanner
}
}
private fun updateJumpToBottomViewVisibility() {
debouncer.debounce("jump_to_bottom_visibility", 250, Runnable {
Timber.v("First visible: ${layoutManager.findFirstCompletelyVisibleItemPosition()}")
@ -662,13 +675,12 @@ class RoomDetailFragment @Inject constructor(
}
private fun renderState(state: RoomDetailViewState) {
readMarkerHelper.updateWith(state)
renderRoomSummary(state)
val summary = state.asyncRoomSummary()
val inviter = state.asyncInviter()
if (summary?.membership == Membership.JOIN) {
scrollOnHighlightedEventCallback.timeline = state.timeline
timelineEventController.update(state, readMarkerHelper.readMarkerVisible())
timelineEventController.update(state)
inviteView.visibility = View.GONE
val uid = session.myUserId
val meMember = session.getRoom(state.roomId)?.getRoomMember(uid)
@ -1024,28 +1036,9 @@ class RoomDetailFragment @Inject constructor(
.show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS")
}
override fun onReadMarkerLongBound(readMarkerId: String, isDisplayed: Boolean) {
readMarkerHelper.onReadMarkerLongDisplayed()
val readMarkerIndex = timelineEventController.searchPositionOfEvent(readMarkerId) ?: return
val lastVisibleItemPosition = layoutManager.findLastVisibleItemPosition()
if (readMarkerIndex > lastVisibleItemPosition) {
return
}
val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
var nextReadMarkerId: String? = null
for (itemPosition in firstVisibleItemPosition until lastVisibleItemPosition) {
val timelineItem = timelineEventController.adapter.getModelAtPosition(itemPosition)
if (timelineItem is BaseEventItem) {
val eventId = timelineItem.getEventIds().firstOrNull() ?: continue
if (!LocalEcho.isLocalEchoId(eventId)) {
nextReadMarkerId = eventId
break
}
}
}
if (nextReadMarkerId != null) {
roomDetailViewModel.handle(RoomDetailAction.SetReadMarkerAction(nextReadMarkerId))
}
override fun onReadMarkerVisible() {
updateJumpToReadMarkerViewVisibility()
roomDetailViewModel.handle(RoomDetailAction.EnterTrackingUnreadMessagesState)
}
// AutocompleteUserPresenter.Callback
@ -1252,8 +1245,14 @@ class RoomDetailFragment @Inject constructor(
// JumpToReadMarkerView.Callback
override fun onJumpToReadMarkerClicked(readMarkerId: String) {
roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(readMarkerId, false))
override fun onJumpToReadMarkerClicked() = withState(roomDetailViewModel) {
jumpToReadMarkerView.isVisible = false
if (it.unreadState is UnreadState.HasUnread) {
roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.firstUnreadEventId, false))
}
if (it.unreadState is UnreadState.ReadMarkerNotLoaded) {
roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.readMarkerId, false))
}
}
override fun onClearReadMarkerClicked() {

View file

@ -22,6 +22,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.airbnb.mvrx.*
import com.jakewharton.rxrelay2.BehaviorRelay
import com.jakewharton.rxrelay2.PublishRelay
import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.MatrixCallback
@ -35,11 +36,14 @@ import im.vector.matrix.android.api.session.file.FileService
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilities
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.matrix.android.api.session.room.send.UserDraft
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
@ -58,19 +62,23 @@ import im.vector.riotx.features.command.CommandParser
import im.vector.riotx.features.command.ParsedCommand
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDisplayableEvents
import im.vector.riotx.features.settings.VectorPreferences
import io.reactivex.Observable
import io.reactivex.functions.BiFunction
import io.reactivex.rxkotlin.subscribeBy
import io.reactivex.schedulers.Schedulers
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import timber.log.Timber
import java.io.File
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: RoomDetailViewState,
userPreferencesProvider: UserPreferencesProvider,
private val vectorPreferences: VectorPreferences,
private val stringProvider: StringProvider,
private val session: Session
) : VectorViewModel<RoomDetailViewState, RoomDetailAction>(initialState) {
) : VectorViewModel<RoomDetailViewState, RoomDetailAction>(initialState), Timeline.Listener {
private val room = session.getRoom(initialState.roomId)!!
private val eventId = initialState.eventId
@ -90,6 +98,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts())
}
private var timelineEvents = PublishRelay.create<List<TimelineEvent>>()
private var timeline = room.createTimeline(eventId, timelineSettings)
// Can be used for several actions, for a one shot result
@ -102,6 +111,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
// Slot to keep a pending uri during permission request
var pendingUri: Uri? = null
private var trackUnreadMessages = AtomicBoolean(false)
private var mostRecentDisplayedEvent: TimelineEvent? = null
@AssistedInject.Factory
interface Factory {
fun create(initialState: RoomDetailViewState): RoomDetailViewModel
@ -120,48 +132,67 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
init {
getUnreadState()
observeSyncState()
observeRoomSummary()
observeEventDisplayedActions()
observeSummaryState()
observeDrafts()
observeUnreadState()
room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear()
timeline.addListener(this)
timeline.start()
setState { copy(timeline = this@RoomDetailViewModel.timeline) }
}
override fun handle(action: RoomDetailAction) {
when (action) {
is RoomDetailAction.SaveDraft -> handleSaveDraft(action)
is RoomDetailAction.SendMessage -> handleSendMessage(action)
is RoomDetailAction.SendMedia -> handleSendMedia(action)
is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action)
is RoomDetailAction.TimelineEventTurnsInvisible -> handleEventInvisible(action)
is RoomDetailAction.LoadMoreTimelineEvents -> handleLoadMore(action)
is RoomDetailAction.SendReaction -> handleSendReaction(action)
is RoomDetailAction.AcceptInvite -> handleAcceptInvite()
is RoomDetailAction.RejectInvite -> handleRejectInvite()
is RoomDetailAction.RedactAction -> handleRedactEvent(action)
is RoomDetailAction.UndoReaction -> handleUndoReact(action)
is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action)
is RoomDetailAction.ExitSpecialMode -> handleExitSpecialMode(action)
is RoomDetailAction.EnterEditMode -> handleEditAction(action)
is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action)
is RoomDetailAction.EnterReplyMode -> handleReplyAction(action)
is RoomDetailAction.DownloadFile -> handleDownloadFile(action)
is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action)
is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action)
is RoomDetailAction.ResendMessage -> handleResendEvent(action)
is RoomDetailAction.RemoveFailedEcho -> handleRemove(action)
is RoomDetailAction.ClearSendQueue -> handleClearSendQueue()
is RoomDetailAction.ResendAll -> handleResendAll()
is RoomDetailAction.SetReadMarkerAction -> handleSetReadMarkerAction(action)
is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead()
is RoomDetailAction.ReportContent -> handleReportContent(action)
is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action)
is RoomDetailAction.SaveDraft -> handleSaveDraft(action)
is RoomDetailAction.SendMessage -> handleSendMessage(action)
is RoomDetailAction.SendMedia -> handleSendMedia(action)
is RoomDetailAction.TimelineEventTurnsVisible -> handleEventVisible(action)
is RoomDetailAction.TimelineEventTurnsInvisible -> handleEventInvisible(action)
is RoomDetailAction.LoadMoreTimelineEvents -> handleLoadMore(action)
is RoomDetailAction.SendReaction -> handleSendReaction(action)
is RoomDetailAction.AcceptInvite -> handleAcceptInvite()
is RoomDetailAction.RejectInvite -> handleRejectInvite()
is RoomDetailAction.RedactAction -> handleRedactEvent(action)
is RoomDetailAction.UndoReaction -> handleUndoReact(action)
is RoomDetailAction.UpdateQuickReactAction -> handleUpdateQuickReaction(action)
is RoomDetailAction.ExitSpecialMode -> handleExitSpecialMode(action)
is RoomDetailAction.EnterEditMode -> handleEditAction(action)
is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action)
is RoomDetailAction.EnterReplyMode -> handleReplyAction(action)
is RoomDetailAction.DownloadFile -> handleDownloadFile(action)
is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action)
is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action)
is RoomDetailAction.ResendMessage -> handleResendEvent(action)
is RoomDetailAction.RemoveFailedEcho -> handleRemove(action)
is RoomDetailAction.ClearSendQueue -> handleClearSendQueue()
is RoomDetailAction.ResendAll -> handleResendAll()
is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead()
is RoomDetailAction.ReportContent -> handleReportContent(action)
is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action)
is RoomDetailAction.EnterTrackingUnreadMessagesState -> startTrackingUnreadMessages()
is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages()
}
}
private fun startTrackingUnreadMessages() {
trackUnreadMessages.set(true)
setState { copy(canShowJumpToReadMarker = false) }
}
private fun stopTrackingUnreadMessages() {
if (trackUnreadMessages.getAndSet(false)) {
mostRecentDisplayedEvent?.root?.eventId?.also {
room.setReadMarker(it, callback = object : MatrixCallback<Unit> {})
}
mostRecentDisplayedEvent = null
}
setState { copy(canShowJumpToReadMarker = true) }
}
private fun handleEventInvisible(action: RoomDetailAction.TimelineEventTurnsInvisible) {
invisibleEventsObservable.accept(action)
}
@ -627,6 +658,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
private fun handleNavigateToEvent(action: RoomDetailAction.NavigateToEvent) {
stopTrackingUnreadMessages()
val targetEventId: String = action.eventId
val correctedEventId = timeline.getFirstDisplayableEventId(targetEventId) ?: targetEventId
val indexOfEvent = timeline.getIndexOfEvent(correctedEventId)
@ -685,26 +717,23 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
.buffer(1, TimeUnit.SECONDS)
.filter { it.isNotEmpty() }
.subscribeBy(onNext = { actions ->
val mostRecentEvent = actions.maxBy { it.event.displayIndex }
mostRecentEvent?.event?.root?.eventId?.let { eventId ->
val bufferedMostRecentDisplayedEvent = actions.maxBy { it.event.displayIndex }?.event
?: return@subscribeBy
val globalMostRecentDisplayedEvent = mostRecentDisplayedEvent
if (trackUnreadMessages.get()) {
if (globalMostRecentDisplayedEvent == null) {
mostRecentDisplayedEvent = bufferedMostRecentDisplayedEvent
} else if (bufferedMostRecentDisplayedEvent.displayIndex > globalMostRecentDisplayedEvent.displayIndex) {
mostRecentDisplayedEvent = bufferedMostRecentDisplayedEvent
}
}
bufferedMostRecentDisplayedEvent.root.eventId?.let { eventId ->
room.setReadReceipt(eventId, callback = object : MatrixCallback<Unit> {})
}
})
.disposeOnClear()
}
private fun handleSetReadMarkerAction(action: RoomDetailAction.SetReadMarkerAction) = withState {
var readMarkerId = action.eventId
val indexOfEvent = timeline.getIndexOfEvent(readMarkerId)
// force to set the read marker on the next event
if (indexOfEvent != null) {
timeline.getTimelineEventAtIndex(indexOfEvent - 1)?.root?.eventId?.also { eventIdOfNext ->
readMarkerId = eventIdOfNext
}
}
room.setReadMarker(readMarkerId, callback = object : MatrixCallback<Unit> {})
}
private fun handleMarkAllAsRead() {
room.markAllAsRead(object : MatrixCallback<Any> {})
}
@ -759,6 +788,56 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}
private fun getUnreadState() {
Observable
.combineLatest<List<TimelineEvent>, RoomSummary, UnreadState>(
timelineEvents.observeOn(Schedulers.computation()),
room.rx().liveRoomSummary().unwrap(),
BiFunction { timelineEvents, roomSummary ->
computeUnreadState(timelineEvents, roomSummary)
}
)
// We don't want live update of unread so we skip when we already had a HasUnread or HasNoUnread
.distinctUntilChanged { previous, current ->
when {
previous is UnreadState.Unknown || previous is UnreadState.ReadMarkerNotLoaded -> false
current is UnreadState.HasUnread || current is UnreadState.HasNoUnread -> true
else -> false
}
}
.subscribe {
setState { copy(unreadState = it) }
}
.disposeOnClear()
}
private fun computeUnreadState(events: List<TimelineEvent>, roomSummary: RoomSummary): UnreadState {
if (events.isEmpty()) return UnreadState.Unknown
val readMarkerIdSnapshot = roomSummary.readMarkerId ?: return UnreadState.Unknown
val firstDisplayableEventId = timeline.getFirstDisplayableEventId(readMarkerIdSnapshot)
?: return UnreadState.ReadMarkerNotLoaded(readMarkerIdSnapshot)
val firstDisplayableEventIndex = timeline.getIndexOfEvent(firstDisplayableEventId)
?: return UnreadState.ReadMarkerNotLoaded(readMarkerIdSnapshot)
for (i in (firstDisplayableEventIndex - 1) downTo 0) {
val timelineEvent = events.getOrNull(i) ?: return UnreadState.Unknown
val eventId = timelineEvent.root.eventId ?: return UnreadState.Unknown
val isFromMe = timelineEvent.root.senderId == session.myUserId
if (!isFromMe) {
return UnreadState.HasUnread(eventId)
}
}
return UnreadState.HasNoUnread
}
private fun observeUnreadState() {
selectSubscribe(RoomDetailViewState::unreadState) {
Timber.v("Unread state: $it")
if (it is UnreadState.HasNoUnread) {
startTrackingUnreadMessages()
}
}
}
private fun observeSummaryState() {
asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary ->
if (summary.membership == Membership.INVITE) {
@ -774,8 +853,13 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}
override fun onUpdated(snapshot: List<TimelineEvent>) {
timelineEvents.accept(snapshot)
}
override fun onCleared() {
timeline.dispose()
timeline.removeAllListeners()
super.onCleared()
}
}

View file

@ -41,6 +41,13 @@ sealed class SendMode(open val text: String) {
data class REPLY(val timelineEvent: TimelineEvent, override val text: String) : SendMode(text)
}
sealed class UnreadState {
object Unknown : UnreadState()
object HasNoUnread : UnreadState()
data class ReadMarkerNotLoaded(val readMarkerId: String): UnreadState()
data class HasUnread(val firstUnreadEventId: String) : UnreadState()
}
data class RoomDetailViewState(
val roomId: String,
val eventId: String?,
@ -52,7 +59,9 @@ data class RoomDetailViewState(
val tombstoneEvent: Event? = null,
val tombstoneEventHandling: Async<String> = Uninitialized,
val syncState: SyncState = SyncState.IDLE,
val highlightedEventId: String? = null
val highlightedEventId: String? = null,
val unreadState: UnreadState = UnreadState.Unknown,
val canShowJumpToReadMarker: Boolean = true
) : MvRxState {
constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId)

View file

@ -25,21 +25,18 @@ import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.VisibilityState
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.core.date.VectorDateFormatter
import im.vector.riotx.core.epoxy.LoadingItem_
import im.vector.riotx.core.extensions.localDateTime
import im.vector.riotx.core.utils.DimensionConverter
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.RoomDetailViewState
import im.vector.riotx.features.home.room.detail.UnreadState
import im.vector.riotx.features.home.room.detail.timeline.factory.MergedHeaderItemFactory
import im.vector.riotx.features.home.room.detail.timeline.factory.TimelineItemFactory
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotx.features.home.room.detail.timeline.helper.nextOrNull
import im.vector.riotx.features.home.room.detail.timeline.helper.*
import im.vector.riotx.features.home.room.detail.timeline.item.*
import im.vector.riotx.features.media.ImageContentRenderer
import im.vector.riotx.features.media.VideoContentRenderer
@ -47,11 +44,10 @@ import org.threeten.bp.LocalDateTime
import javax.inject.Inject
class TimelineEventController @Inject constructor(private val dateFormatter: VectorDateFormatter,
private val session: Session,
private val timelineItemFactory: TimelineItemFactory,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val mergedHeaderItemFactory: MergedHeaderItemFactory,
private val avatarRenderer: AvatarRenderer,
private val dimensionConverter: DimensionConverter,
@TimelineEventControllerHandler
private val backgroundHandler: Handler
) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor {
@ -86,7 +82,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
interface ReadReceiptsCallback {
fun onReadReceiptsClicked(readReceipts: List<ReadReceiptData>)
fun onReadMarkerLongBound(readMarkerId: String, isDisplayed: Boolean)
fun onReadMarkerVisible()
}
interface UrlClickCallback {
@ -101,6 +97,9 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
private var currentSnapshot: List<TimelineEvent> = emptyList()
private var inSubmitList: Boolean = false
private var timeline: Timeline? = null
private var unreadState: UnreadState = UnreadState.Unknown
private var positionOfReadMarker: Int? = null
private var eventIdToHighlight: String? = null
var callback: Callback? = null
@ -152,7 +151,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
}
// Update position when we are building new items
override fun intercept(models: MutableList<EpoxyModel<*>>) {
override fun intercept(models: MutableList<EpoxyModel<*>>) = synchronized(modelCache) {
positionOfReadMarker = null
adapterPositionMapping.clear()
models.forEachIndexed { index, epoxyModel ->
if (epoxyModel is BaseEventItem) {
@ -161,18 +161,25 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
}
}
}
val currentUnreadState = this.unreadState
if (currentUnreadState is UnreadState.HasUnread) {
val position = adapterPositionMapping[currentUnreadState.firstUnreadEventId]?.plus(1)
positionOfReadMarker = position
if (position != null) {
val readMarker = TimelineReadMarkerItem_()
.also {
it.id("read_marker")
it.setOnVisibilityStateChanged(ReadMarkerVisibilityStateChangedListener(callback))
}
models.add(position, readMarker)
}
}
}
fun update(viewState: RoomDetailViewState, readMarkerVisible: Boolean) {
if (timeline != viewState.timeline) {
fun update(viewState: RoomDetailViewState) {
if (timeline?.timelineID != viewState.timeline?.timelineID) {
timeline = viewState.timeline
timeline?.listener = this
// Clear cache
synchronized(modelCache) {
for (i in 0 until modelCache.size) {
modelCache[i] = null
}
}
timeline?.addListener(this)
}
var requestModelBuild = false
if (eventIdToHighlight != viewState.highlightedEventId) {
@ -188,8 +195,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
eventIdToHighlight = viewState.highlightedEventId
requestModelBuild = true
}
if (this.readMarkerVisible != readMarkerVisible) {
this.readMarkerVisible = readMarkerVisible
if (this.unreadState != viewState.unreadState) {
this.unreadState = viewState.unreadState
requestModelBuild = true
}
if (requestModelBuild) {
@ -197,9 +204,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
}
}
private var readMarkerVisible: Boolean = false
private var eventIdToHighlight: String? = null
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
timelineMediaSizeProvider.recyclerView = recyclerView
@ -224,7 +228,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
}
}
// Timeline.LISTENER ***************************************************************************
// Timeline.LISTENER ***************************************************************************
override fun onUpdated(snapshot: List<TimelineEvent>) {
submitSnapshot(snapshot)
@ -246,43 +250,40 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
}
private fun getModels(): List<EpoxyModel<*>> {
synchronized(modelCache) {
(0 until modelCache.size).forEach { position ->
// Should be build if not cached or if cached but contains mergedHeader or formattedDay
// We then are sure we always have items up to date.
if (modelCache[position] == null
|| modelCache[position]?.mergedHeaderModel != null
|| modelCache[position]?.formattedDayModel != null) {
modelCache[position] = buildItemModels(position, currentSnapshot)
}
}
return modelCache
.map {
val eventModel = if (it == null || mergedHeaderItemFactory.isCollapsed(it.localId)) {
null
} else {
it.eventModel
}
listOf(eventModel, it?.mergedHeaderModel, it?.formattedDayModel)
buildCacheItemsIfNeeded()
return modelCache
.map {
val eventModel = if (it == null || mergedHeaderItemFactory.isCollapsed(it.localId)) {
null
} else {
it.eventModel
}
.flatten()
.filterNotNull()
listOf(eventModel, it?.mergedHeaderModel, it?.formattedDayModel)
}
.flatten()
.filterNotNull()
}
private fun buildCacheItemsIfNeeded() = synchronized(modelCache) {
if (modelCache.isEmpty()) {
return
}
(0 until modelCache.size).forEach { position ->
// Should be build if not cached or if cached but contains additional models
// We then are sure we always have items up to date.
if (modelCache[position] == null || modelCache[position]?.shouldTriggerBuild() == true) {
modelCache[position] = buildCacheItem(position, currentSnapshot)
}
}
}
private fun buildItemModels(currentPosition: Int, items: List<TimelineEvent>): CacheItemData {
private fun buildCacheItem(currentPosition: Int, items: List<TimelineEvent>): CacheItemData {
val event = items[currentPosition]
val nextEvent = items.nextOrNull(currentPosition)
val date = event.root.localDateTime()
val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
// Don't show read marker if it's on first item
val showReadMarker = if (currentPosition == 0 && event.hasReadMarker) {
false
} else {
readMarkerVisible
}
val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, showReadMarker, callback).also {
val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, callback).also {
it.id(event.localId)
it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event))
}
@ -290,7 +291,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
nextEvent = nextEvent,
items = items,
addDaySeparator = addDaySeparator,
readMarkerVisible = readMarkerVisible,
currentPosition = currentPosition,
eventIdToHighlight = eventIdToHighlight,
callback = callback
@ -298,7 +298,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
requestModelBuild()
}
val daySeparatorItem = buildDaySeparatorItem(addDaySeparator, date)
return CacheItemData(event.localId, event.root.eventId, eventModel, mergedHeaderModel, daySeparatorItem)
}
@ -335,6 +334,10 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
return adapterPositionMapping[eventId]
}
fun getPositionOfReadMarker(): Int? = synchronized(modelCache) {
return positionOfReadMarker
}
fun isLoadingForward() = showingForwardLoader
private data class CacheItemData(
@ -343,5 +346,9 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
val eventModel: EpoxyModel<*>? = null,
val mergedHeaderModel: MergedHeaderItem? = null,
val formattedDayModel: DaySeparatorItem? = null
)
) {
fun shouldTriggerBuild(): Boolean {
return mergedHeaderModel != null || formattedDayModel != null
}
}
}

View file

@ -46,7 +46,6 @@ class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: Ava
fun create(event: TimelineEvent,
highlight: Boolean,
readMarkerVisible: Boolean,
callback: TimelineEventController.Callback?,
exception: Exception? = null): DefaultItem {
val text = if (exception == null) {
@ -54,7 +53,7 @@ class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: Ava
} else {
"an exception occurred when rendering the event ${event.root.eventId}"
}
val informationData = informationDataFactory.create(event, null, readMarkerVisible)
val informationData = informationDataFactory.create(event, null)
return create(text, informationData, highlight, callback)
}
}

View file

@ -42,7 +42,6 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat
fun create(event: TimelineEvent,
nextEvent: TimelineEvent?,
highlight: Boolean,
readMarkerVisible: Boolean,
callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? {
event.root.eventId ?: return null
@ -66,7 +65,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat
// TODO This is not correct format for error, change it
val informationData = messageInformationDataFactory.create(event, nextEvent, readMarkerVisible)
val informationData = messageInformationDataFactory.create(event, nextEvent)
val attributes = attributesFactory.create(null, informationData, callback)
return MessageTextItem_()
.leftGuideline(avatarSizeProvider.leftGuideline)

View file

@ -36,7 +36,6 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act
nextEvent: TimelineEvent?,
items: List<TimelineEvent>,
addDaySeparator: Boolean,
readMarkerVisible: Boolean,
currentPosition: Int,
eventIdToHighlight: String?,
callback: TimelineEventController.Callback?,
@ -50,20 +49,12 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act
null
} else {
var highlighted = false
var readMarkerId: String? = null
var showReadMarker = false
val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed()
val mergedData = ArrayList<MergedHeaderItem.Data>(mergedEvents.size)
mergedEvents.forEach { mergedEvent ->
if (!highlighted && mergedEvent.root.eventId == eventIdToHighlight) {
highlighted = true
}
if (readMarkerId == null && mergedEvent.hasReadMarker) {
readMarkerId = mergedEvent.root.eventId
}
if (!showReadMarker && mergedEvent.hasReadMarker && readMarkerVisible) {
showReadMarker = true
}
val senderAvatar = mergedEvent.senderAvatar
val senderName = mergedEvent.getDisambiguatedDisplayName()
val data = MergedHeaderItem.Data(
@ -96,8 +87,6 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act
mergeItemCollapseStates[event.localId] = it
requestModelBuild()
},
readMarkerId = readMarkerId,
showReadMarker = isCollapsed && showReadMarker,
readReceiptsCallback = callback
)
MergedHeaderItem_()

View file

@ -69,12 +69,11 @@ class MessageItemFactory @Inject constructor(
fun create(event: TimelineEvent,
nextEvent: TimelineEvent?,
highlight: Boolean,
readMarkerVisible: Boolean,
callback: TimelineEventController.Callback?
): VectorEpoxyModel<*>? {
event.root.eventId ?: return null
val informationData = messageInformationDataFactory.create(event, nextEvent, readMarkerVisible)
val informationData = messageInformationDataFactory.create(event, nextEvent)
if (event.root.isRedacted()) {
// message is redacted
@ -91,7 +90,7 @@ class MessageItemFactory @Inject constructor(
|| event.isEncrypted() && event.root.content.toModel<EncryptedEventContent>()?.relatesTo?.type == RelationType.REPLACE
) {
// This is an edit event, we should it when debugging as a notice event
return noticeItemFactory.create(event, highlight, readMarkerVisible, callback)
return noticeItemFactory.create(event, highlight, callback)
}
val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback)

View file

@ -34,10 +34,9 @@ class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEv
fun create(event: TimelineEvent,
highlight: Boolean,
readMarkerVisible: Boolean,
callback: TimelineEventController.Callback?): NoticeItem? {
val formattedText = eventFormatter.format(event) ?: return null
val informationData = informationDataFactory.create(event, null, readMarkerVisible)
val informationData = informationDataFactory.create(event, null)
val attributes = NoticeItem.Attributes(
avatarRenderer = avatarRenderer,
informationData = informationData,

View file

@ -33,14 +33,13 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
fun create(event: TimelineEvent,
nextEvent: TimelineEvent?,
eventIdToHighlight: String?,
readMarkerVisible: Boolean,
callback: TimelineEventController.Callback?): VectorEpoxyModel<*> {
val highlight = event.root.eventId == eventIdToHighlight
val computedModel = try {
when (event.root.getClearType()) {
EventType.STICKER,
EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, readMarkerVisible, callback)
EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, callback)
// State and call
EventType.STATE_ROOM_TOMBSTONE,
EventType.STATE_ROOM_NAME,
@ -53,21 +52,21 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
EventType.CALL_ANSWER,
EventType.REACTION,
EventType.REDACTION,
EventType.ENCRYPTION -> noticeItemFactory.create(event, highlight, readMarkerVisible, callback)
EventType.ENCRYPTION -> noticeItemFactory.create(event, highlight, callback)
// State room create
EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(event, callback)
// Crypto
EventType.ENCRYPTED -> {
if (event.root.isRedacted()) {
// Redacted event, let the MessageItemFactory handle it
messageItemFactory.create(event, nextEvent, highlight, readMarkerVisible, callback)
messageItemFactory.create(event, nextEvent, highlight, callback)
} else {
encryptedItemFactory.create(event, nextEvent, highlight, readMarkerVisible, callback)
encryptedItemFactory.create(event, nextEvent, highlight, callback)
}
}
// Unhandled event types (yet)
EventType.STATE_ROOM_THIRD_PARTY_INVITE -> defaultItemFactory.create(event, highlight, readMarkerVisible, callback)
EventType.STATE_ROOM_THIRD_PARTY_INVITE -> defaultItemFactory.create(event, highlight, callback)
else -> {
Timber.v("Type ${event.root.getClearType()} not handled")
null
@ -75,7 +74,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
}
} catch (e: Exception) {
Timber.e(e, "failed to create message item")
defaultItemFactory.create(event, highlight, readMarkerVisible, callback, e)
defaultItemFactory.create(event, highlight, callback, e)
}
return (computedModel ?: EmptyItem_())
}

View file

@ -39,7 +39,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
private val dateFormatter: VectorDateFormatter,
private val colorProvider: ColorProvider) {
fun create(event: TimelineEvent, nextEvent: TimelineEvent?, readMarkerVisible: Boolean): MessageInformationData {
fun create(event: TimelineEvent, nextEvent: TimelineEvent?): MessageInformationData {
// Non nullability has been tested before
val eventId = event.root.eventId!!
@ -47,7 +47,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60))
?: false
?: false
val showInformation =
addDaySeparator
@ -63,8 +63,6 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId ?: ""))
}
val displayReadMarker = readMarkerVisible && event.hasReadMarker
return MessageInformationData(
eventId = eventId,
senderId = event.root.senderId ?: "",
@ -88,9 +86,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
.map {
ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs)
}
.toList(),
hasReadMarker = event.hasReadMarker,
displayReadMarker = displayReadMarker
.toList()
)
}
}

View file

@ -21,6 +21,16 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
class ReadMarkerVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?)
: VectorEpoxyModel.OnVisibilityStateChangedListener {
override fun onVisibilityStateChanged(visibilityState: Int) {
if (visibilityState == VisibilityState.VISIBLE) {
callback?.onReadMarkerVisible()
}
}
}
class TimelineEventVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?,
private val event: TimelineEvent)
: VectorEpoxyModel.OnVisibilityStateChangedListener {

View file

@ -27,7 +27,6 @@ import com.airbnb.epoxy.EpoxyAttribute
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.riotx.R
import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.ui.views.ReadMarkerView
import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
@ -50,13 +49,6 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
attributes.readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts)
})
private val _readMarkerCallback = object : ReadMarkerView.Callback {
override fun onReadMarkerLongBound(isDisplayed: Boolean) {
attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.informationData.eventId, isDisplayed)
}
}
var reactionClickListener: ReactionButton.ReactedListener = object : ReactionButton.ReactedListener {
override fun onReacted(reactionButton: ReactionButton) {
attributes.reactionPillCallback?.onClickOnReactionPill(attributes.informationData, reactionButton.reactionString, true)
@ -110,12 +102,6 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
attributes.avatarRenderer,
_readReceiptsClickListener
)
holder.readMarkerView.bindView(
attributes.informationData.eventId,
attributes.informationData.hasReadMarker,
attributes.informationData.displayReadMarker,
_readMarkerCallback
)
val reactions = attributes.informationData.orderedReactionList
if (!shouldShowReactionAtBottom() || reactions.isNullOrEmpty()) {
@ -138,7 +124,6 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
}
override fun unbind(holder: H) {
holder.readMarkerView.unbind()
holder.readReceiptsView.unbind()
super.unbind(holder)
}

View file

@ -19,14 +19,12 @@ import android.view.View
import android.view.ViewStub
import android.widget.RelativeLayout
import androidx.annotation.IdRes
import androidx.core.view.marginStart
import androidx.core.view.updateLayoutParams
import com.airbnb.epoxy.EpoxyAttribute
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.platform.CheckableView
import im.vector.riotx.core.ui.views.ReadMarkerView
import im.vector.riotx.core.ui.views.ReadReceiptsView
import im.vector.riotx.core.utils.DimensionConverter
@ -62,7 +60,6 @@ abstract class BaseEventItem<H : BaseEventItem.BaseHolder> : VectorEpoxyModel<H>
val leftGuideline by bind<View>(R.id.messageStartGuideline)
val checkableBackground by bind<CheckableView>(R.id.messageSelectedBackground)
val readReceiptsView by bind<ReadReceiptsView>(R.id.readReceiptsView)
val readMarkerView by bind<ReadMarkerView>(R.id.readMarkerView)
override fun bindView(itemView: View) {
super.bindView(itemView)

View file

@ -25,7 +25,6 @@ import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R
import im.vector.riotx.core.ui.views.ReadMarkerView
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
@ -39,13 +38,6 @@ abstract class MergedHeaderItem : BaseEventItem<MergedHeaderItem.Holder>() {
attributes.mergeData.distinctBy { it.userId }
}
private val _readMarkerCallback = object : ReadMarkerView.Callback {
override fun onReadMarkerLongBound(isDisplayed: Boolean) {
attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.readMarkerId ?: "", isDisplayed)
}
}
override fun getViewType() = STUB_ID
override fun bind(holder: Holder) {
@ -77,20 +69,14 @@ abstract class MergedHeaderItem : BaseEventItem<MergedHeaderItem.Holder>() {
}
// No read receipt for this item
holder.readReceiptsView.isVisible = false
holder.readMarkerView.bindView(
attributes.readMarkerId,
!attributes.readMarkerId.isNullOrEmpty(),
attributes.showReadMarker,
_readMarkerCallback)
}
override fun unbind(holder: Holder) {
holder.readMarkerView.unbind()
super.unbind(holder)
}
override fun getEventIds(): List<String> {
return attributes.mergeData.map { it.eventId }
return if (attributes.isCollapsed) {
attributes.mergeData.map { it.eventId }
} else {
emptyList()
}
}
data class Data(
@ -102,9 +88,7 @@ abstract class MergedHeaderItem : BaseEventItem<MergedHeaderItem.Holder>() {
)
data class Attributes(
val readMarkerId: String?,
val isCollapsed: Boolean,
val showReadMarker: Boolean,
val mergeData: List<Data>,
val avatarRenderer: AvatarRenderer,
val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null,
@ -119,6 +103,6 @@ abstract class MergedHeaderItem : BaseEventItem<MergedHeaderItem.Holder>() {
}
companion object {
private const val STUB_ID = R.id.messageContentMergedheaderStub
private const val STUB_ID = R.id.messageContentMergedHeaderStub
}
}

View file

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

View file

@ -22,7 +22,6 @@ import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R
import im.vector.riotx.core.ui.views.ReadMarkerView
import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
@ -37,13 +36,6 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
attributes.readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts)
})
private val _readMarkerCallback = object : ReadMarkerView.Callback {
override fun onReadMarkerLongBound(isDisplayed: Boolean) {
attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.informationData.eventId, isDisplayed)
}
}
override fun bind(holder: Holder) {
super.bind(holder)
holder.noticeTextView.text = attributes.noticeText
@ -56,17 +48,6 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
)
holder.view.setOnLongClickListener(attributes.itemLongClickListener)
holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener)
holder.readMarkerView.bindView(
attributes.informationData.eventId,
attributes.informationData.hasReadMarker,
attributes.informationData.displayReadMarker,
_readMarkerCallback
)
}
override fun unbind(holder: Holder) {
holder.readMarkerView.unbind()
super.unbind(holder)
}
override fun getEventIds(): List<String> {

View file

@ -0,0 +1,31 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.item
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel
@EpoxyModelClass(layout = R.layout.item_timeline_read_marker)
abstract class TimelineReadMarkerItem : VectorEpoxyModel<TimelineReadMarkerItem.Holder>() {
override fun bind(holder: Holder) {
}
class Holder : VectorEpoxyHolder()
}

View file

@ -11,7 +11,7 @@
android:id="@+id/messageSelectedBackground"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignBottom="@+id/readMarkerView"
android:layout_alignBottom="@+id/informationBottom"
android:layout_alignParentTop="true"
android:background="?riotx_highlighted_message_background" />
@ -145,15 +145,4 @@
</LinearLayout>
<im.vector.riotx.core.ui.views.ReadMarkerView
android:id="@+id/readMarkerView"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_below="@+id/informationBottom"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="2dp"
android:background="?attr/vctr_unread_marker_line_color"
android:visibility="invisible" />
</RelativeLayout>

View file

@ -10,7 +10,7 @@
android:id="@+id/messageSelectedBackground"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignBottom="@+id/informationBottom"
android:layout_alignBottom="@+id/readReceiptsView"
android:layout_alignParentTop="true"
android:background="?riotx_highlighted_message_background" />
@ -47,37 +47,19 @@
android:layout="@layout/item_timeline_event_blank_stub" />
<ViewStub
android:id="@+id/messageContentMergedheaderStub"
android:id="@+id/messageContentMergedHeaderStub"
style="@style/TimelineContentStubBaseParams"
android:layout="@layout/item_timeline_event_merged_header_stub" />
</FrameLayout>
<LinearLayout
android:id="@+id/informationBottom"
android:layout_width="match_parent"
<im.vector.riotx.core.ui.views.ReadReceiptsView
android:id="@+id/readReceiptsView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/viewStubContainer"
android:orientation="vertical">
<im.vector.riotx.core.ui.views.ReadReceiptsView
android:id="@+id/readReceiptsView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp" />
<im.vector.riotx.core.ui.views.ReadMarkerView
android:id="@+id/readMarkerView"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="2dp"
android:background="?attr/vctr_unread_marker_line_color"
android:visibility="invisible" />
</LinearLayout>
android:layout_alignParentEnd="true"
android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp" />
</RelativeLayout>

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
<FrameLayout 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"
@ -8,50 +7,23 @@
android:padding="8dp">
<View
android:id="@+id/itemDayLineViewLeft"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="32dp"
android:layout_marginRight="32dp"
android:layout_marginBottom="8dp"
android:background="?riotx_header_panel_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/itemDayTextView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
android:layout_marginEnd="8dp"
android:background="?riotx_header_panel_background" />
<TextView
android:id="@+id/itemDayTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_gravity="center"
android:background="?riotx_background"
android:paddingStart="32dp"
android:paddingEnd="32dp"
android:textColor="?riotx_header_panel_text_primary"
android:textSize="15sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/date/day_of_week" />
<View
android:id="@+id/itemDayLineViewRight"
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_marginStart="32dp"
android:layout_marginLeft="32dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="8dp"
android:background="?riotx_header_panel_background"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/itemDayTextView"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?riotx_background"
android:padding="8dp">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_gravity="center_vertical"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:background="@color/notification_accent_color" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="?riotx_background"
android:fontFamily="sans-serif-medium"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:text="@string/timeline_unread_messages"
android:textColor="@color/notification_accent_color"
android:textSize="15sp" />
</FrameLayout>

View file

@ -22,6 +22,7 @@
<string name="room_join_rules_public">%1$s made the room public to whoever knows the link.</string>
<string name="room_join_rules_invite">%1$s made the room invite only.</string>
<string name="timeline_unread_messages">Unread messages</string>
<string name="login_splash_title">Liberate your communication</string>
<string name="login_splash_text1">Chat with people directly or in groups</string>