Timeline: continue fixing issues + read marker

This commit is contained in:
ganfra 2019-09-18 20:21:42 +02:00
parent 3066d5f303
commit 88fb9667a3
31 changed files with 331 additions and 153 deletions

View File

@ -21,13 +21,14 @@ import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.model.ReadReceipt import im.vector.matrix.android.api.session.room.model.ReadReceipt
import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.RoomSummary
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.util.Optional
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.Single import io.reactivex.Single
class RxRoom(private val room: Room) { class RxRoom(private val room: Room) {
fun liveRoomSummary(): Observable<RoomSummary> { fun liveRoomSummary(): Observable<RoomSummary> {
return room.liveRoomSummary().asObservable() return room.getRoomSummaryLive().asObservable()
} }
fun liveRoomMemberIds(): Observable<List<String>> { fun liveRoomMemberIds(): Observable<List<String>> {
@ -39,7 +40,15 @@ class RxRoom(private val room: Room) {
} }
fun liveTimelineEvent(eventId: String): Observable<TimelineEvent> { fun liveTimelineEvent(eventId: String): Observable<TimelineEvent> {
return room.liveTimeLineEvent(eventId).asObservable() return room.getTimeLineEventLive(eventId).asObservable()
}
fun liveReadMarker(): Observable<Optional<String>> {
return room.getReadMarkerLive().asObservable()
}
fun liveReadReceipt(): Observable<Optional<String>> {
return room.getMyReadReceiptLive().asObservable()
} }
fun loadRoomMembersIfNeeded(): Single<Unit> = Single.create { fun loadRoomMembersIfNeeded(): Single<Unit> = Single.create {

View File

@ -47,7 +47,7 @@ interface Room :
* A live [RoomSummary] associated with the room * A live [RoomSummary] associated with the room
* You can observe this summary to get dynamic data from this room. * You can observe this summary to get dynamic data from this room.
*/ */
fun liveRoomSummary(): LiveData<RoomSummary> fun getRoomSummaryLive(): LiveData<RoomSummary>
fun roomSummary(): RoomSummary? fun roomSummary(): RoomSummary?

View File

@ -19,6 +19,7 @@ package im.vector.matrix.android.api.session.room.read
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.room.model.ReadReceipt import im.vector.matrix.android.api.session.room.model.ReadReceipt
import im.vector.matrix.android.api.util.Optional
/** /**
* This interface defines methods to handle read receipts and read marker in a room. It's implemented at the room level. * This interface defines methods to handle read receipts and read marker in a room. It's implemented at the room level.
@ -40,12 +41,24 @@ interface ReadService {
*/ */
fun setReadMarker(fullyReadEventId: String, callback: MatrixCallback<Unit>) fun setReadMarker(fullyReadEventId: String, callback: MatrixCallback<Unit>)
/**
* Check if an event is already read, ie. your read receipt is set on a more recent event.
*/
fun isEventRead(eventId: String): Boolean fun isEventRead(eventId: String): Boolean
/** /**
* Returns a nullable read marker for the room. * Returns a live read marker id for the room.
*/ */
fun getReadMarkerLive(): LiveData<String?> fun getReadMarkerLive(): LiveData<Optional<String>>
/**
* Returns a live read receipt id for the room.
*/
fun getMyReadReceiptLive(): LiveData<Optional<String>>
/**
* Returns a live list of read receipts for a given event
* @param eventId: the event
*/
fun getEventReadReceiptsLive(eventId: String): LiveData<List<ReadReceipt>> fun getEventReadReceiptsLive(eventId: String): LiveData<List<ReadReceipt>>
} }

View File

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

View File

@ -0,0 +1,40 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.api.util
data class Optional<T : Any> constructor(private val value: T?) {
fun get(): T {
return value!!
}
fun getOrNull(): T? {
return value
}
fun getOrElse(fn: () -> T): T {
return value ?: fn()
}
companion object {
fun <T : Any> from(value: T?): Optional<T> {
return Optional(value)
}
}
}

View File

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

View File

@ -25,6 +25,7 @@ import im.vector.matrix.android.api.MatrixCallback
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.model.ReadReceipt import im.vector.matrix.android.api.session.room.model.ReadReceipt
import im.vector.matrix.android.api.session.room.read.ReadService import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.util.Optional
import im.vector.matrix.android.internal.database.RealmLiveData import im.vector.matrix.android.internal.database.RealmLiveData
import im.vector.matrix.android.internal.database.mapper.ReadReceiptsSummaryMapper import im.vector.matrix.android.internal.database.mapper.ReadReceiptsSummaryMapper
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
@ -83,24 +84,33 @@ internal class DefaultReadService @AssistedInject constructor(@Assisted private
var isEventRead = false var isEventRead = false
monarchy.doWithRealm { monarchy.doWithRealm {
val readReceipt = ReadReceiptEntity.where(it, roomId, credentials.userId).findFirst() val readReceipt = ReadReceiptEntity.where(it, roomId, credentials.userId).findFirst()
?: return@doWithRealm ?: return@doWithRealm
val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(it, roomId) val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(it, roomId)
?: return@doWithRealm ?: return@doWithRealm
val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex
?: Int.MIN_VALUE ?: Int.MIN_VALUE
val eventToCheckIndex = liveChunk.timelineEvents.find(eventId)?.root?.displayIndex val eventToCheckIndex = liveChunk.timelineEvents.find(eventId)?.root?.displayIndex
?: Int.MAX_VALUE ?: Int.MAX_VALUE
isEventRead = eventToCheckIndex <= readReceiptIndex isEventRead = eventToCheckIndex <= readReceiptIndex
} }
return isEventRead return isEventRead
} }
override fun getReadMarkerLive(): LiveData<String?> { override fun getReadMarkerLive(): LiveData<Optional<String>> {
val liveRealmData = RealmLiveData(monarchy.realmConfiguration) { realm -> val liveRealmData = RealmLiveData(monarchy.realmConfiguration) { realm ->
ReadMarkerEntity.where(realm, roomId) ReadMarkerEntity.where(realm, roomId)
} }
return Transformations.map(liveRealmData) { results -> return Transformations.map(liveRealmData) { results ->
results.firstOrNull()?.eventId Optional.from(results.firstOrNull()?.eventId)
}
}
override fun getMyReadReceiptLive(): LiveData<Optional<String>> {
val liveRealmData = RealmLiveData(monarchy.realmConfiguration) { realm ->
ReadReceiptEntity.where(realm, roomId = roomId, userId = credentials.userId)
}
return Transformations.map(liveRealmData) { results ->
Optional.from(results.firstOrNull()?.eventId)
} }
} }

View File

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package im.vector.matrix.android.api.session.room.read package im.vector.matrix.android.internal.session.room.read
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass

View File

@ -18,7 +18,6 @@ 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
@ -76,29 +75,49 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI
if (fullyReadEventId != null && isReadMarkerMoreRecent(params.roomId, fullyReadEventId)) { if (fullyReadEventId != null && isReadMarkerMoreRecent(params.roomId, fullyReadEventId)) {
if (LocalEchoEventFactory.isLocalEchoId(fullyReadEventId)) { if (LocalEchoEventFactory.isLocalEchoId(fullyReadEventId)) {
Timber.w("Can't set read marker for local event ${params.fullyReadEventId}") Timber.w("Can't set read marker for local event $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 $readReceiptEventId")
} else { } else {
updateNotificationCountIfNecessary(params.roomId, readReceiptEventId)
markers[READ_RECEIPT] = readReceiptEventId markers[READ_RECEIPT] = readReceiptEventId
} }
} }
if (markers.isEmpty()) { if (markers.isEmpty()) {
return return
} }
updateDatabase(params.roomId, markers)
executeRequest<Unit> { executeRequest<Unit> {
apiCall = roomAPI.sendReadMarker(params.roomId, markers) apiCall = roomAPI.sendReadMarker(params.roomId, markers)
} }
} }
private suspend fun updateDatabase(roomId: String, markers: HashMap<String, String>) {
monarchy.awaitTransaction { realm ->
val readMarkerId = markers[READ_MARKER]
val readReceiptId = markers[READ_RECEIPT]
if (readMarkerId != null) {
roomFullyReadHandler.handle(realm, roomId, FullyReadContent(readMarkerId))
}
if (readReceiptId != null) {
val isLatestReceived = TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId == readReceiptId
if (isLatestReceived) {
val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst()
?: return@awaitTransaction
roomSummary.notificationCount = 0
roomSummary.highlightCount = 0
}
}
}
}
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 ->
@ -111,36 +130,36 @@ internal class DefaultSetReadMarkersTask @Inject constructor(private val roomAPI
} }
} }
private suspend fun updateReadMarker(roomId: String, eventId: String) {
monarchy.awaitTransaction { realm ->
roomFullyReadHandler.handle(realm, roomId, FullyReadContent(eventId))
}
}
private suspend fun updateNotificationCountIfNecessary(roomId: String, eventId: String) {
monarchy.awaitTransaction { realm ->
val isLatestReceived = TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId == eventId
if (isLatestReceived) {
val roomSummary = RoomSummaryEntity.where(realm, roomId).findFirst()
?: return@awaitTransaction
roomSummary.notificationCount = 0
roomSummary.highlightCount = 0
}
}
}
private fun isEventRead(roomId: String, eventId: String): Boolean { private fun isEventRead(roomId: String, eventId: String): Boolean {
return Realm.getInstance(monarchy.realmConfiguration).use { realm -> return Realm.getInstance(monarchy.realmConfiguration).use { realm ->
val readReceipt = ReadReceiptEntity.where(realm, roomId, credentials.userId).findFirst() val readReceipt = ReadReceiptEntity.where(realm, roomId, credentials.userId).findFirst()
?: return false ?: return false
val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId) val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId)
?: return false ?: return false
val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex val readReceiptIndex = liveChunk.timelineEvents.find(readReceipt.eventId)?.root?.displayIndex
?: Int.MIN_VALUE ?: Int.MIN_VALUE
val eventToCheckIndex = liveChunk.timelineEvents.find(eventId)?.root?.displayIndex val eventToCheckIndex = liveChunk.timelineEvents.find(eventId)?.root?.displayIndex
?: Int.MAX_VALUE ?: Int.MAX_VALUE
eventToCheckIndex <= readReceiptIndex eventToCheckIndex <= readReceiptIndex
} }
} }
private fun SetReadMarkersTask.Params.fullyReadEventId(): String? {
if (fullyReadEventId != null) {
return this.fullyReadEventId
} else {
Realm.getInstance(monarchy.realmConfiguration).use { realm ->
val readReceipt = ReadReceiptEntity.where(realm, roomId, credentials.userId).findFirst()
val readMarker = ReadMarkerEntity.where(realm, roomId).findFirst()
return if (readMarker?.eventId == readReceipt?.eventId) {
readReceiptEventId
} else {
null
}
}
}
}
} }

View File

@ -73,7 +73,7 @@ internal class DefaultTimelineService @AssistedInject constructor(@Assisted priv
}) })
} }
override fun liveTimeLineEvent(eventId: String): LiveData<TimelineEvent> { override fun getTimeLineEventLive(eventId: String): LiveData<TimelineEvent> {
val liveData = RealmLiveData(monarchy.realmConfiguration) { val liveData = RealmLiveData(monarchy.realmConfiguration) {
TimelineEventEntity.where(it, eventId = eventId) TimelineEventEntity.where(it, eventId = eventId)
} }

View File

@ -16,8 +16,7 @@
package im.vector.matrix.android.internal.session.sync package im.vector.matrix.android.internal.session.sync
import im.vector.matrix.android.api.session.room.read.FullyReadContent import im.vector.matrix.android.internal.session.room.read.FullyReadContent
import im.vector.matrix.android.internal.database.model.EventEntity
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.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity

View File

@ -23,7 +23,7 @@ import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.tag.RoomTagContent import im.vector.matrix.android.api.session.room.model.tag.RoomTagContent
import im.vector.matrix.android.api.session.room.read.FullyReadContent import im.vector.matrix.android.internal.session.room.read.FullyReadContent
import im.vector.matrix.android.internal.database.helper.add import im.vector.matrix.android.internal.database.helper.add
import im.vector.matrix.android.internal.database.helper.addOrUpdate import im.vector.matrix.android.internal.database.helper.addOrUpdate
import im.vector.matrix.android.internal.database.helper.addStateEvent import im.vector.matrix.android.internal.database.helper.addStateEvent

View File

@ -184,7 +184,7 @@ class PushrulesConditionTest {
} }
class MockRoom(override val roomId: String, val _numberOfJoinedMembers: Int) : Room { class MockRoom(override val roomId: String, val _numberOfJoinedMembers: Int) : Room {
override fun liveTimeLineEvent(eventId: String): LiveData<TimelineEvent> { override fun getTimeLineEventLive(eventId: String): LiveData<TimelineEvent> {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates. TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
} }
@ -193,7 +193,7 @@ class PushrulesConditionTest {
return _numberOfJoinedMembers return _numberOfJoinedMembers
} }
override fun liveRoomSummary(): LiveData<RoomSummary> { override fun getRoomSummaryLive(): LiveData<RoomSummary> {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates. TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
} }

View File

@ -23,8 +23,8 @@ import android.util.AttributeSet
import android.view.View import android.view.View
import android.view.animation.Animation import android.view.animation.Animation
import android.view.animation.AnimationUtils import android.view.animation.AnimationUtils
import androidx.core.view.isInvisible
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import kotlinx.coroutines.* import kotlinx.coroutines.*
private const val DELAY_IN_MS = 1_500L private const val DELAY_IN_MS = 1_500L
@ -36,30 +36,34 @@ class ReadMarkerView @JvmOverloads constructor(
) : View(context, attrs, defStyleAttr) { ) : View(context, attrs, defStyleAttr) {
interface Callback { interface Callback {
fun onReadMarkerDisplayed() fun onReadMarkerLongBound()
} }
private var eventId: String? = null
private var callback: Callback? = null private var callback: Callback? = null
private var callbackDispatcherJob: Job? = null private var callbackDispatcherJob: Job? = null
fun bindView(displayReadMarker: Boolean, readMarkerCallback: Callback) { fun bindView(eventId: String?, hasReadMarker: Boolean, displayReadMarker: Boolean, readMarkerCallback: Callback) {
this.eventId = eventId
this.callback = readMarkerCallback this.callback = readMarkerCallback
if (displayReadMarker) { if (displayReadMarker) {
visibility = VISIBLE
callbackDispatcherJob = GlobalScope.launch(Dispatchers.Main) {
delay(DELAY_IN_MS)
callback?.onReadMarkerDisplayed()
}
startAnimation() startAnimation()
} else { } else {
visibility = INVISIBLE this.animation?.cancel()
this.visibility = INVISIBLE
}
if (hasReadMarker) {
callbackDispatcherJob = GlobalScope.launch(Dispatchers.Main) {
delay(DELAY_IN_MS)
callback?.onReadMarkerLongBound()
}
} }
} }
fun unbind() { fun unbind() {
this.callbackDispatcherJob?.cancel() this.callbackDispatcherJob?.cancel()
this.callback = null this.callback = null
this.eventId = null
this.animation?.cancel() this.animation?.cancel()
this.visibility = INVISIBLE this.visibility = INVISIBLE
} }
@ -80,6 +84,7 @@ class ReadMarkerView @JvmOverloads constructor(
override fun onAnimationRepeat(animation: Animation) {} override fun onAnimationRepeat(animation: Animation) {}
}) })
} }
visibility = VISIBLE
animation.start() animation.start()
} }

View File

@ -33,6 +33,10 @@ internal class Debouncer(private val handler: Handler) {
return true return true
} }
fun cancelAll() {
handler.removeCallbacksAndMessages(null)
}
private fun insertRunnable(identifier: String, r: Runnable, millis: Long) { private fun insertRunnable(identifier: String, r: Runnable, millis: Long) {
val chained = Runnable { val chained = Runnable {
handler.post(r) handler.post(r)
@ -41,4 +45,6 @@ internal class Debouncer(private val handler: Handler) {
runnables[identifier] = chained runnables[identifier] = chained
handler.postDelayed(chained, millis) handler.postDelayed(chained, millis)
} }
} }

View File

@ -44,7 +44,6 @@ import androidx.core.content.ContextCompat
import androidx.core.util.Pair import androidx.core.util.Pair
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.forEach import androidx.core.view.forEach
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -312,17 +311,21 @@ class RoomDetailFragment :
} }
} }
override fun onDestroy() {
debouncer.cancelAll()
super.onDestroy()
}
private fun setupJumpToBottomView() { private fun setupJumpToBottomView() {
jumpToBottomView.isVisible = false jumpToBottomView.visibility = View.INVISIBLE
jumpToBottomView.setOnClickListener { jumpToBottomView.setOnClickListener {
jumpToBottomView.visibility = View.INVISIBLE
withState(roomDetailViewModel) { state -> withState(roomDetailViewModel) { state ->
recyclerView.stopScroll()
if (state.timeline?.isLive == false) { if (state.timeline?.isLive == false) {
state.timeline.restartWithEventId(null) state.timeline.restartWithEventId(null)
} else { } else {
layoutManager.scrollToPosition(0) layoutManager.scrollToPosition(0)
} }
jumpToBottomView.isVisible = false
} }
} }
} }
@ -398,17 +401,17 @@ class RoomDetailFragment :
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) { if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
val parser = Parser.builder().build() val parser = Parser.builder().build()
val document = parser.parse(messageContent.formattedBody val document = parser.parse(messageContent.formattedBody
?: messageContent.body) ?: messageContent.body)
formattedBody = eventHtmlRenderer.render(document) formattedBody = eventHtmlRenderer.render(document)
} }
composerLayout.composerRelatedMessageContent.text = formattedBody composerLayout.composerRelatedMessageContent.text = formattedBody
?: nonFormattedBody ?: nonFormattedBody
composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "") composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "")
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
avatarRenderer.render(event.senderAvatar, event.root.senderId avatarRenderer.render(event.senderAvatar, event.root.senderId
?: "", event.senderName, composerLayout.composerRelatedMessageAvatar) ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar)
composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length) composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
composerLayout.expand { composerLayout.expand {
@ -437,9 +440,9 @@ class RoomDetailFragment :
REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data) REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data)
REACTION_SELECT_REQUEST_CODE -> { REACTION_SELECT_REQUEST_CODE -> {
val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID) val eventId = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_EVENT_ID)
?: return ?: return
val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT) val reaction = data.getStringExtra(EmojiReactionPickerActivity.EXTRA_REACTION_RESULT)
?: return ?: return
//TODO check if already reacted with that? //TODO check if already reacted with that?
roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId)) roomDetailViewModel.process(RoomDetailActions.SendReaction(reaction, eventId))
} }
@ -490,33 +493,33 @@ class RoomDetailFragment :
if (vectorPreferences.swipeToReplyIsEnabled()) { if (vectorPreferences.swipeToReplyIsEnabled()) {
val swipeCallback = RoomMessageTouchHelperCallback(requireContext(), val swipeCallback = RoomMessageTouchHelperCallback(requireContext(),
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)?.attributes?.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))
} }
} }
override fun canSwipeModel(model: EpoxyModel<*>): Boolean { override fun canSwipeModel(model: EpoxyModel<*>): Boolean {
return when (model) { return when (model) {
is MessageFileItem, is MessageFileItem,
is MessageImageVideoItem, is MessageImageVideoItem,
is MessageTextItem -> { is MessageTextItem -> {
return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED return (model as AbsMessageItem).attributes.informationData.sendState == SendState.SYNCED
} }
else -> false else -> false
} }
} }
}) })
val touchHelper = ItemTouchHelper(swipeCallback) val touchHelper = ItemTouchHelper(swipeCallback)
touchHelper.attachToRecyclerView(recyclerView) touchHelper.attachToRecyclerView(recyclerView)
} }
} }
private fun updateJumpToBottomViewVisibility() { private fun updateJumpToBottomViewVisibility() {
debouncer.debounce("jump_to_bottom_visibility", 100, Runnable { debouncer.debounce("jump_to_bottom_visibility", 250, Runnable {
Timber.v("First visible: ${layoutManager.findFirstCompletelyVisibleItemPosition()}") Timber.v("First visible: ${layoutManager.findFirstCompletelyVisibleItemPosition()}")
if (layoutManager.findFirstCompletelyVisibleItemPosition() != 0) { if (layoutManager.findFirstCompletelyVisibleItemPosition() != 0) {
jumpToBottomView.show() jumpToBottomView.show()
@ -684,7 +687,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.highlightedEventId) timelineEventController.update(state.timeline, state.highlightedEventId, state.hideReadMarker)
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)
@ -747,7 +750,6 @@ class RoomDetailFragment :
} }
} }
private fun renderSendMessageResult(sendMessageResult: SendMessageResult) { private fun renderSendMessageResult(sendMessageResult: SendMessageResult) {
when (sendMessageResult) { when (sendMessageResult) {
is SendMessageResult.MessageSent -> { is SendMessageResult.MessageSent -> {
@ -945,15 +947,15 @@ class RoomDetailFragment :
.show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS") .show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS")
} }
override fun onReadMarkerLongDisplayed(informationData: MessageInformationData) { override fun onReadMarkerLongDisplayed() = withState(roomDetailViewModel) { state ->
val firstVisibleItem = layoutManager.findFirstVisibleItemPosition() val firstVisibleItem = layoutManager.findFirstVisibleItemPosition()
val eventId = timelineEventController.searchEventIdAtPosition(firstVisibleItem) val nextReadMarkerId = timelineEventController.searchEventIdAtPosition(firstVisibleItem)
if (eventId != null) { if (nextReadMarkerId != null) {
roomDetailViewModel.process(RoomDetailActions.SetReadMarkerAction(eventId)) roomDetailViewModel.process(RoomDetailActions.SetReadMarkerAction(nextReadMarkerId))
} }
} }
// AutocompleteUserPresenter.Callback // AutocompleteUserPresenter.Callback
override fun onQueryUsers(query: CharSequence?) { override fun onQueryUsers(query: CharSequence?) {
textComposerViewModel.process(TextComposerActions.QueryUsers(query)) textComposerViewModel.process(TextComposerActions.QueryUsers(query))

View File

@ -40,13 +40,13 @@ 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
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
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.TimelineSettings import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
import im.vector.matrix.android.api.util.Optional
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
import im.vector.matrix.rx.rx import im.vector.matrix.rx.rx
@ -62,6 +62,7 @@ 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.Observable
import io.reactivex.functions.BiFunction
import io.reactivex.functions.Function3 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
@ -116,6 +117,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
observeEventDisplayedActions() observeEventDisplayedActions()
observeSummaryState() observeSummaryState()
observeJumpToReadMarkerViewVisibility() observeJumpToReadMarkerViewVisibility()
observeReadMarkerVisibility()
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) }
@ -156,7 +158,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
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
val roomId = tombstoneContent.replacementRoom ?: "" val roomId = tombstoneContent.replacementRoom ?: ""
val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN
@ -303,7 +305,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
//is original event a reply? //is original event a reply?
val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel<MessageContent>()?.relatesTo?.inReplyTo?.eventId val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel<MessageContent>()?.relatesTo?.inReplyTo?.eventId
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId ?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId
if (inReplyTo != null) { if (inReplyTo != null) {
//TODO check if same content? //TODO check if same content?
room.getTimeLineEvent(inReplyTo)?.let { room.getTimeLineEvent(inReplyTo)?.let {
@ -312,12 +314,12 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} else { } else {
val messageContent: MessageContent? = val messageContent: MessageContent? =
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel() ?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val existingBody = messageContent?.body ?: "" val existingBody = messageContent?.body ?: ""
if (existingBody != action.text) { if (existingBody != action.text) {
room.editTextMessage(state.sendMode.timelineEvent.root.eventId room.editTextMessage(state.sendMode.timelineEvent.root.eventId
?: "", messageContent?.type ?: "", messageContent?.type
?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown) ?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown)
} else { } else {
Timber.w("Same message content, do not send edition") Timber.w("Same message content, do not send edition")
} }
@ -332,7 +334,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is SendMode.QUOTE -> { is SendMode.QUOTE -> {
val messageContent: MessageContent? = val messageContent: MessageContent? =
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel() state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel() ?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val textMsg = messageContent?.body val textMsg = messageContent?.body
val finalText = legacyRiotQuoteText(textMsg, action.text) val finalText = legacyRiotQuoteText(textMsg, action.text)
@ -635,29 +637,30 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} }
private fun observeJumpToReadMarkerViewVisibility() { private fun observeJumpToReadMarkerViewVisibility() {
Observable Observable.combineLatest(
.combineLatest( room.rx().liveRoomSummary()
room.rx().liveRoomSummary().map { .map {
val readMarkerId = it.readMarkerId val readMarkerId = it.readMarkerId
if (readMarkerId == null) { if (readMarkerId == null) {
Option.empty() Option.empty()
} else { } else {
val timelineEvent = room.getTimeLineEvent(readMarkerId) val readMarkerIndex = room.getTimeLineEvent(readMarkerId)?.displayIndex
Option.fromNullable(timelineEvent) ?: Int.MIN_VALUE
} Option.just(readMarkerIndex)
}.distinctUntilChanged(),
visibleEventsObservable.distinctUntilChanged(),
isEventVisibleObservable { it.hasReadMarker }.startWith(false),
Function3<Option<TimelineEvent>, RoomDetailActions.TimelineEventTurnsVisible, Boolean, Boolean> { readMarkerEvent, currentVisibleEvent, isReadMarkerViewVisible ->
if (readMarkerEvent.isEmpty() || isReadMarkerViewVisible) {
false
} else {
val readMarkerPosition = readMarkerEvent.map { it.displayIndex }.getOrElse { Int.MIN_VALUE }
val currentVisibleEventPosition = currentVisibleEvent.event.displayIndex
readMarkerPosition < currentVisibleEventPosition
} }
} }
) .distinctUntilChanged(),
visibleEventsObservable.distinctUntilChanged(),
isEventVisibleObservable { it.hasReadMarker }.startWith(false).takeUntil { it },
Function3<Option<Int>, RoomDetailActions.TimelineEventTurnsVisible, Boolean, Boolean> { readMarkerIndex, currentVisibleEvent, isReadMarkerViewVisible ->
if (readMarkerIndex.isEmpty() || isReadMarkerViewVisible) {
false
} else {
val currentVisibleEventPosition = currentVisibleEvent.event.displayIndex
readMarkerIndex.getOrElse { Int.MIN_VALUE } < currentVisibleEventPosition
}
}
)
.distinctUntilChanged() .distinctUntilChanged()
.subscribe { .subscribe {
setState { copy(showJumpToReadMarker = it) } setState { copy(showJumpToReadMarker = it) }
@ -682,6 +685,25 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} }
} }
private fun observeReadMarkerVisibility() {
Observable
.combineLatest(
room.rx().liveReadMarker(),
room.rx().liveReadReceipt(),
BiFunction<Optional<String>, Optional<String>, Boolean> { readMarker, readReceipt ->
readMarker.getOrNull() == readReceipt.getOrNull()
}
)
.throttleLast(250, TimeUnit.MILLISECONDS)
.distinctUntilChanged()
.startWith(false)
.subscribe {
setState { copy(hideReadMarker = it) }
}
.disposeOnClear()
}
private fun observeSummaryState() { private fun observeSummaryState() {
asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary -> asyncSubscribe(RoomDetailViewState::asyncRoomSummary) { summary ->
if (summary.membership == Membership.INVITE) { if (summary.membership == Membership.INVITE) {

View File

@ -53,7 +53,8 @@ data class RoomDetailViewState(
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 showJumpToReadMarker: Boolean = false,
val highlightedEventId: String? = null val highlightedEventId: String? = null,
val hideReadMarker: Boolean = false
) : MvRxState { ) : MvRxState {
constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId) constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId)

View File

@ -26,7 +26,7 @@ class ScrollOnNewMessageCallback(private val layoutManager: LinearLayoutManager,
override fun onInserted(position: Int, count: Int) { override fun onInserted(position: Int, count: Int) {
Timber.v("On inserted $count count at position: $position") Timber.v("On inserted $count count at position: $position")
if (position == 0 && layoutManager.findFirstVisibleItemPosition() == 0 && !timelineEventController.isLoadingForward()) { if (position == 0 && layoutManager.findFirstCompletelyVisibleItemPosition() == 0 && !timelineEventController.isLoadingForward()) {
layoutManager.scrollToPosition(0) layoutManager.scrollToPosition(0)
} }
} }

View File

@ -31,8 +31,6 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.core.date.VectorDateFormatter import im.vector.riotx.core.date.VectorDateFormatter
import im.vector.riotx.core.epoxy.LoadingItem_ import im.vector.riotx.core.epoxy.LoadingItem_
import im.vector.riotx.core.extensions.localDateTime import im.vector.riotx.core.extensions.localDateTime
import im.vector.riotx.core.resources.UserPreferencesProvider
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.factory.MergedHeaderItemFactory 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.factory.TimelineItemFactory
import im.vector.riotx.features.home.room.detail.timeline.helper.* import im.vector.riotx.features.home.room.detail.timeline.helper.*
@ -80,7 +78,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) fun onReadMarkerLongDisplayed()
} }
interface UrlClickCallback { interface UrlClickCallback {
@ -142,7 +140,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
requestModelBuild() requestModelBuild()
} }
fun setTimeline(timeline: Timeline?, eventIdToHighlight: String?) { fun update(timeline: Timeline?, eventIdToHighlight: String?, hideReadMarker: Boolean) {
if (this.timeline != timeline) { if (this.timeline != timeline) {
this.timeline = timeline this.timeline = timeline
this.timeline?.listener = this this.timeline?.listener = this
@ -155,22 +153,30 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
} }
} }
var requestModelBuild = false
if (this.eventIdToHighlight != eventIdToHighlight) { if (this.eventIdToHighlight != eventIdToHighlight) {
// Clear cache to force a refresh // Clear cache to force a refresh
synchronized(modelCache) { synchronized(modelCache) {
for (i in 0 until modelCache.size) { for (i in 0 until modelCache.size) {
if (modelCache[i]?.eventId == eventIdToHighlight if (modelCache[i]?.eventId == eventIdToHighlight
|| modelCache[i]?.eventId == this.eventIdToHighlight) { || modelCache[i]?.eventId == this.eventIdToHighlight) {
modelCache[i] = null modelCache[i] = null
} }
} }
} }
this.eventIdToHighlight = eventIdToHighlight this.eventIdToHighlight = eventIdToHighlight
requestModelBuild = true
}
if (this.hideReadMarker != hideReadMarker) {
this.hideReadMarker = hideReadMarker
requestModelBuild = true
}
if (requestModelBuild) {
requestModelBuild() requestModelBuild()
} }
} }
private var hideReadMarker: Boolean = false
private var eventIdToHighlight: String? = null private var eventIdToHighlight: String? = null
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
@ -224,8 +230,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
// Should be build if not cached or if cached but contains mergedHeader or formattedDay // 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. // We then are sure we always have items up to date.
if (modelCache[position] == null if (modelCache[position] == null
|| modelCache[position]?.mergedHeaderModel != null || modelCache[position]?.mergedHeaderModel != null
|| modelCache[position]?.formattedDayModel != null) { || modelCache[position]?.formattedDayModel != null) {
modelCache[position] = buildItemModels(position, currentSnapshot) modelCache[position] = buildItemModels(position, currentSnapshot)
} }
} }
@ -251,7 +257,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
val nextDate = nextEvent?.root?.localDateTime() val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate() val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, callback).also { val eventModel = timelineItemFactory.create(event, nextEvent, eventIdToHighlight, hideReadMarker, callback).also {
it.id(event.localId) it.id(event.localId)
it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event)) it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event))
} }

View File

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

View File

@ -41,6 +41,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat
fun create(event: TimelineEvent, fun create(event: TimelineEvent,
nextEvent: TimelineEvent?, nextEvent: TimelineEvent?,
highlight: Boolean, highlight: Boolean,
hideReadMarker: Boolean,
callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? { callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? {
event.root.eventId ?: return null event.root.eventId ?: return null
@ -64,7 +65,7 @@ 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, hideReadMarker)
val attributes = attributesFactory.create(null, informationData, callback) val attributes = attributesFactory.create(null, informationData, callback)
return MessageTextItem_() return MessageTextItem_()
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)

View File

@ -51,18 +51,22 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act
return if (!event.canBeMerged() || (nextEvent?.root?.getClearType() == event.root.getClearType() && !addDaySeparator)) { return if (!event.canBeMerged() || (nextEvent?.root?.getClearType() == event.root.getClearType() && !addDaySeparator)) {
null null
} else { } else {
var highlighted = false
var showReadMarker = false
val prevSameTypeEvents = items.prevSameTypeEvents(currentPosition, 2) val prevSameTypeEvents = items.prevSameTypeEvents(currentPosition, 2)
if (prevSameTypeEvents.isEmpty()) { if (prevSameTypeEvents.isEmpty()) {
null null
} else { } else {
var highlighted = false
var readMarkerId: String? = null
var showReadMarker = false
val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed() val mergedEvents = (prevSameTypeEvents + listOf(event)).asReversed()
val mergedData = ArrayList<MergedHeaderItem.Data>(mergedEvents.size) val mergedData = ArrayList<MergedHeaderItem.Data>(mergedEvents.size)
mergedEvents.forEach { mergedEvent -> mergedEvents.forEach { mergedEvent ->
if (!highlighted && mergedEvent.root.eventId == eventIdToHighlight) { if (!highlighted && mergedEvent.root.eventId == eventIdToHighlight) {
highlighted = true highlighted = true
} }
if (readMarkerId == null && mergedEvent.hasReadMarker) {
readMarkerId = mergedEvent.root.eventId
}
if (!showReadMarker && mergedEvent.displayReadMarker(sessionHolder.getActiveSession().myUserId)) { if (!showReadMarker && mergedEvent.displayReadMarker(sessionHolder.getActiveSession().myUserId)) {
showReadMarker = true showReadMarker = true
} }
@ -81,7 +85,7 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act
// => handle case where paginating from mergeable events and we get more // => handle case where paginating from mergeable events and we get more
val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull() val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull()
val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey) val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey)
?: true ?: true
val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState } val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState }
if (isCollapsed) { if (isCollapsed) {
collapsedEventIds.addAll(mergedEventIds) collapsedEventIds.addAll(mergedEventIds)
@ -97,12 +101,14 @@ class MergedHeaderItemFactory @Inject constructor(private val sessionHolder: Act
mergeItemCollapseStates[event.localId] = it mergeItemCollapseStates[event.localId] = it
requestModelBuild() requestModelBuild()
}, },
showReadMarker = showReadMarker readMarkerId = readMarkerId,
showReadMarker = isCollapsed && showReadMarker,
readReceiptsCallback = callback
) )
MergedHeaderItem_() MergedHeaderItem_()
.id(mergeId) .id(mergeId)
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.highlighted(highlighted) .highlighted(isCollapsed && highlighted)
.attributes(attributes) .attributes(attributes)
.also { .also {
it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents)) it.setOnVisibilityStateChanged(MergedTimelineEventVisibilityStateChangedListener(callback, mergedEvents))

View File

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

View File

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

View File

@ -33,13 +33,14 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
fun create(event: TimelineEvent, fun create(event: TimelineEvent,
nextEvent: TimelineEvent?, nextEvent: TimelineEvent?,
eventIdToHighlight: String?, eventIdToHighlight: String?,
hideReadMarker: Boolean,
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 {
when (event.root.getClearType()) { when (event.root.getClearType()) {
EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, callback) EventType.MESSAGE -> messageItemFactory.create(event, nextEvent, highlight, hideReadMarker, callback)
// State and call // State and call
EventType.STATE_ROOM_TOMBSTONE, EventType.STATE_ROOM_TOMBSTONE,
EventType.STATE_ROOM_NAME, EventType.STATE_ROOM_NAME,
@ -51,22 +52,22 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
EventType.CALL_ANSWER, EventType.CALL_ANSWER,
EventType.REACTION, EventType.REACTION,
EventType.REDACTION, EventType.REDACTION,
EventType.ENCRYPTION -> noticeItemFactory.create(event, highlight, callback) EventType.ENCRYPTION -> noticeItemFactory.create(event, highlight, hideReadMarker, 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.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
messageItemFactory.create(event, nextEvent, highlight, callback) messageItemFactory.create(event, nextEvent, highlight, hideReadMarker, callback)
} else { } else {
encryptedItemFactory.create(event, nextEvent, highlight, callback) encryptedItemFactory.create(event, nextEvent, highlight, hideReadMarker, callback)
} }
} }
// Unhandled event types (yet) // Unhandled event types (yet)
EventType.STATE_ROOM_THIRD_PARTY_INVITE, EventType.STATE_ROOM_THIRD_PARTY_INVITE,
EventType.STICKER -> defaultItemFactory.create(event, highlight, callback) EventType.STICKER -> defaultItemFactory.create(event, highlight, hideReadMarker, callback)
else -> { else -> {
Timber.v("Type ${event.root.getClearType()} not handled") Timber.v("Type ${event.root.getClearType()} not handled")
null null
@ -74,7 +75,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "failed to create message item") Timber.e(e, "failed to create message item")
defaultItemFactory.create(event, highlight, callback, e) defaultItemFactory.create(event, highlight, hideReadMarker, callback, e)
} }
return (computedModel ?: EmptyItem_()) return (computedModel ?: EmptyItem_())
} }

View File

@ -41,7 +41,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
private val dateFormatter: VectorDateFormatter, private val dateFormatter: VectorDateFormatter,
private val colorProvider: ColorProvider) { private val colorProvider: ColorProvider) {
fun create(event: TimelineEvent, nextEvent: TimelineEvent?): MessageInformationData { fun create(event: TimelineEvent, nextEvent: TimelineEvent?, hideReadMarker: Boolean): MessageInformationData {
// Non nullability has been tested before // Non nullability has been tested before
val eventId = event.root.eventId!! val eventId = event.root.eventId!!
@ -65,7 +65,7 @@ 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.displayReadMarker(session.myUserId) val displayReadMarker = !hideReadMarker && event.displayReadMarker(session.myUserId)
return MessageInformationData( return MessageInformationData(
eventId = eventId, eventId = eventId,
@ -91,6 +91,7 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs) ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs)
} }
.toList(), .toList(),
hasReadMarker = event.hasReadMarker,
displayReadMarker = displayReadMarker displayReadMarker = displayReadMarker
) )
} }

View File

@ -56,8 +56,9 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
}) })
private val _readMarkerCallback = object : ReadMarkerView.Callback { private val _readMarkerCallback = object : ReadMarkerView.Callback {
override fun onReadMarkerDisplayed() {
attributes.readReceiptsCallback?.onReadMarkerLongDisplayed(attributes.informationData) override fun onReadMarkerLongBound() {
attributes.readReceiptsCallback?.onReadMarkerLongDisplayed()
} }
} }
@ -106,7 +107,12 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
holder.memberNameView.setOnLongClickListener(null) holder.memberNameView.setOnLongClickListener(null)
} }
holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener)
holder.readMarkerView.bindView(attributes.informationData.displayReadMarker, _readMarkerCallback) holder.readMarkerView.bindView(
attributes.informationData.eventId,
attributes.informationData.hasReadMarker,
attributes.informationData.displayReadMarker,
_readMarkerCallback
)
if (!shouldShowReactionAtBottom() || attributes.informationData.orderedReactionList.isNullOrEmpty()) { if (!shouldShowReactionAtBottom() || attributes.informationData.orderedReactionList.isNullOrEmpty()) {
holder.reactionWrapper?.isVisible = false holder.reactionWrapper?.isVisible = false

View File

@ -25,7 +25,9 @@ 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.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
@EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo) @EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo)
abstract class MergedHeaderItem : BaseEventItem<MergedHeaderItem.Holder>() { abstract class MergedHeaderItem : BaseEventItem<MergedHeaderItem.Holder>() {
@ -37,6 +39,13 @@ abstract class MergedHeaderItem : BaseEventItem<MergedHeaderItem.Holder>() {
attributes.mergeData.distinctBy { it.userId } attributes.mergeData.distinctBy { it.userId }
} }
private val _readMarkerCallback = object : ReadMarkerView.Callback {
override fun onReadMarkerLongBound() {
attributes.readReceiptsCallback?.onReadMarkerLongDisplayed()
}
}
override fun getViewType() = STUB_ID override fun getViewType() = STUB_ID
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
@ -68,6 +77,16 @@ abstract class MergedHeaderItem : BaseEventItem<MergedHeaderItem.Holder>() {
} }
// No read receipt for this item // No read receipt for this item
holder.readReceiptsView.isVisible = false 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)
} }
data class Data( data class Data(
@ -78,10 +97,12 @@ abstract class MergedHeaderItem : BaseEventItem<MergedHeaderItem.Holder>() {
) )
data class Attributes( data class Attributes(
val readMarkerId: String?,
val isCollapsed: Boolean, val isCollapsed: Boolean,
val showReadMarker: Boolean, val showReadMarker: Boolean,
val mergeData: List<Data>, val mergeData: List<Data>,
val avatarRenderer: AvatarRenderer, val avatarRenderer: AvatarRenderer,
val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null,
val onCollapsedStateChanged: (Boolean) -> Unit val onCollapsedStateChanged: (Boolean) -> Unit
) )

View File

@ -34,6 +34,7 @@ data class MessageInformationData(
val hasBeenEdited: Boolean = false, val hasBeenEdited: Boolean = false,
val hasPendingEdits: Boolean = false, val hasPendingEdits: Boolean = false,
val readReceipts: List<ReadReceiptData> = emptyList(), val readReceipts: List<ReadReceiptData> = emptyList(),
val hasReadMarker: Boolean = false,
val displayReadMarker: Boolean = false val displayReadMarker: Boolean = false
) : Parcelable ) : Parcelable

View File

@ -38,8 +38,8 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
}) })
private val _readMarkerCallback = object : ReadMarkerView.Callback { private val _readMarkerCallback = object : ReadMarkerView.Callback {
override fun onReadMarkerDisplayed() { override fun onReadMarkerLongBound() {
attributes.readReceiptsCallback?.onReadMarkerLongDisplayed(attributes.informationData) attributes.readReceiptsCallback?.onReadMarkerLongDisplayed()
} }
} }
@ -50,12 +50,17 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
attributes.informationData.avatarUrl, attributes.informationData.avatarUrl,
attributes.informationData.senderId, attributes.informationData.senderId,
attributes.informationData.memberName?.toString() attributes.informationData.memberName?.toString()
?: attributes.informationData.senderId, ?: attributes.informationData.senderId,
holder.avatarImageView holder.avatarImageView
) )
holder.view.setOnLongClickListener(attributes.itemLongClickListener) holder.view.setOnLongClickListener(attributes.itemLongClickListener)
holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener)
holder.readMarkerView.bindView(attributes.informationData.displayReadMarker, _readMarkerCallback) holder.readMarkerView.bindView(
attributes.informationData.eventId,
attributes.informationData.hasReadMarker,
attributes.informationData.displayReadMarker,
_readMarkerCallback
)
} }
override fun unbind(holder: Holder) { override fun unbind(holder: Holder) {