Read marker: continue rework [WIP]

This commit is contained in:
ganfra 2019-11-20 16:13:11 +01:00 committed by Benoit Marty
parent ab489df83d
commit d9982076f9
9 changed files with 229 additions and 210 deletions

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
*/
@ -116,4 +122,5 @@ interface Timeline {
*/
BACKWARDS
}
}

View File

@ -81,14 +81,7 @@ internal class DefaultTimeline(
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()
@ -109,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)
@ -295,6 +288,20 @@ internal class DefaultTimeline(
return hasMoreInCache(direction) || !hasReachedEnd(direction)
}
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 {
@ -487,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
@ -564,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) }
}
@ -637,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

@ -34,7 +34,7 @@ class JumpToReadMarkerView @JvmOverloads constructor(
) : RelativeLayout(context, attrs, defStyleAttr) {
interface Callback {
fun onJumpToReadMarkerClicked(readMarkerId: String)
fun onJumpToReadMarkerClicked()
fun onClearReadMarkerClicked()
}
@ -44,24 +44,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,64 +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 jumpToReadMarkerVisible = false
private var state: RoomDetailViewState? = null
fun updateWith(newState: RoomDetailViewState) {
state = newState
checkJumpToReadMarkerVisibility()
}
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

@ -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,
@ -425,7 +424,8 @@ 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)
}
}
@ -474,13 +474,7 @@ 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)
}
checkJumpToUnreadBanner()
}
recyclerView.adapter = timelineEventController.adapter
@ -526,6 +520,25 @@ class RoomDetailFragment @Inject constructor(
}
}
private fun checkJumpToUnreadBanner() = jumpToReadMarkerView.post {
withState(roomDetailViewModel) {
val showJumpToUnreadBanner = when (it.unreadState) {
UnreadState.Unknown,
UnreadState.HasNoUnread -> false
is UnreadState.HasUnread -> {
val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
val positionOfReadMarker = timelineEventController.getPositionOfReadMarker()
if (positionOfReadMarker == null) {
it.timeline?.isLive == true && lastVisibleItem > 0
} else {
positionOfReadMarker > lastVisibleItem
}
}
}
jumpToReadMarkerView.isVisible = showJumpToUnreadBanner
}
}
private fun updateJumpToBottomViewVisibility() {
debouncer.debounce("jump_to_bottom_visibility", 250, Runnable {
Timber.v("First visible: ${layoutManager.findFirstCompletelyVisibleItemPosition()}")
@ -656,7 +669,6 @@ class RoomDetailFragment @Inject constructor(
}
private fun renderState(state: RoomDetailViewState) {
readMarkerHelper.updateWith(state)
renderRoomSummary(state)
val summary = state.asyncRoomSummary()
val inviter = state.asyncInviter()
@ -1018,10 +1030,15 @@ class RoomDetailFragment @Inject constructor(
.show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS")
}
override fun onReadMarkerDisplayed() {
override fun onReadMarkerVisible() {
checkJumpToUnreadBanner()
roomDetailViewModel.handle(RoomDetailAction.EnterTrackingUnreadMessagesState)
}
override fun onReadMarkerInvisible() {
checkJumpToUnreadBanner()
}
// AutocompleteUserPresenter.Callback
override fun onQueryUsers(query: CharSequence?) {
@ -1226,8 +1243,10 @@ class RoomDetailFragment @Inject constructor(
// JumpToReadMarkerView.Callback
override fun onJumpToReadMarkerClicked(readMarkerId: String) {
roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(readMarkerId, false))
override fun onJumpToReadMarkerClicked() = withState(roomDetailViewModel) {
if (it.unreadState is UnreadState.HasUnread) {
roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.eventId, 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,13 @@ 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
@ -59,6 +62,8 @@ import im.vector.riotx.features.command.CommandParser
import im.vector.riotx.features.command.ParsedCommand
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDisplayableEvents
import im.vector.riotx.features.settings.VectorPreferences
import io.reactivex.Observable
import io.reactivex.functions.BiFunction
import io.reactivex.rxkotlin.subscribeBy
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
@ -72,7 +77,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
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
@ -80,18 +85,19 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
private val visibleEventsObservable = BehaviorRelay.create<RoomDetailAction.TimelineEventTurnsVisible>()
private val timelineSettings = if (userPreferencesProvider.shouldShowHiddenEvents()) {
TimelineSettings(30,
filterEdits = false,
filterTypes = true,
allowedTypes = TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES,
buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts())
filterEdits = false,
filterTypes = true,
allowedTypes = TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES,
buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts())
} else {
TimelineSettings(30,
filterEdits = true,
filterTypes = true,
allowedTypes = TimelineDisplayableEvents.DISPLAYABLE_TYPES,
buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts())
filterEdits = true,
filterTypes = true,
allowedTypes = TimelineDisplayableEvents.DISPLAYABLE_TYPES,
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
@ -125,13 +131,15 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
init {
getSnapshotOfReadMarkerId()
getUnreadState()
observeSyncState()
observeRoomSummary()
observeEventDisplayedActions()
observeSummaryState()
observeDrafts()
observeUnreadState()
room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear()
timeline.addListener(this)
timeline.start()
setState { copy(timeline = this@RoomDetailViewModel.timeline) }
}
@ -164,16 +172,16 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead()
is RoomDetailAction.ReportContent -> handleReportContent(action)
is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action)
is RoomDetailAction.EnterTrackingUnreadMessagesState -> handleEnterTrackingUnreadMessages()
is RoomDetailAction.ExitTrackingUnreadMessagesState -> handleExitTrackingUnreadMessages()
is RoomDetailAction.EnterTrackingUnreadMessagesState -> startTrackingUnreadMessages()
is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages()
}
}
private fun handleEnterTrackingUnreadMessages() {
private fun startTrackingUnreadMessages() {
trackUnreadMessages.set(true)
}
private fun handleExitTrackingUnreadMessages() {
private fun stopTrackingUnreadMessages() {
if (trackUnreadMessages.getAndSet(false)) {
mostRecentDisplayedEvent?.root?.eventId?.also {
room.setReadMarker(it, callback = object : MatrixCallback<Unit> {})
@ -212,23 +220,23 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
copy(
// Create a sendMode from a draft and retrieve the TimelineEvent
sendMode = when (draft) {
is UserDraft.REGULAR -> SendMode.REGULAR(draft.text)
is UserDraft.QUOTE -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.QUOTE(timelineEvent, draft.text)
}
}
is UserDraft.REPLY -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.REPLY(timelineEvent, draft.text)
}
}
is UserDraft.EDIT -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.EDIT(timelineEvent, draft.text)
}
}
} ?: SendMode.REGULAR("")
is UserDraft.REGULAR -> SendMode.REGULAR(draft.text)
is UserDraft.QUOTE -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.QUOTE(timelineEvent, draft.text)
}
}
is UserDraft.REPLY -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.REPLY(timelineEvent, draft.text)
}
}
is UserDraft.EDIT -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.EDIT(timelineEvent, draft.text)
}
}
} ?: SendMode.REGULAR("")
)
}
}
@ -237,7 +245,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
private fun handleTombstoneEvent(action: RoomDetailAction.HandleTombstoneEvent) {
val tombstoneContent = action.event.getClearContent().toModel<RoomTombstoneContent>()
?: return
?: return
val roomId = tombstoneContent.replacementRoom ?: ""
val isRoomJoined = session.getRoom(roomId)?.roomSummary()?.membership == Membership.JOIN
@ -375,7 +383,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is SendMode.EDIT -> {
// is original event a reply?
val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel<MessageContent>()?.relatesTo?.inReplyTo?.eventId
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId
?: state.sendMode.timelineEvent.root.content.toModel<EncryptedEventContent>()?.relatesTo?.inReplyTo?.eventId
if (inReplyTo != null) {
// TODO check if same content?
room.getTimeLineEvent(inReplyTo)?.let {
@ -384,13 +392,13 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} else {
val messageContent: MessageContent? =
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val existingBody = messageContent?.body ?: ""
if (existingBody != action.text) {
room.editTextMessage(state.sendMode.timelineEvent.root.eventId ?: "",
messageContent?.type ?: MessageType.MSGTYPE_TEXT,
action.text,
action.autoMarkdown)
messageContent?.type ?: MessageType.MSGTYPE_TEXT,
action.text,
action.autoMarkdown)
} else {
Timber.w("Same message content, do not send edition")
}
@ -401,7 +409,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is SendMode.QUOTE -> {
val messageContent: MessageContent? =
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val textMsg = messageContent?.body
val finalText = legacyRiotQuoteText(textMsg, action.text.toString())
@ -517,7 +525,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
when (val tooBigFile = attachments.find { it.size > maxUploadFileSize }) {
null -> room.sendMedias(attachments)
else -> _fileTooBigEvent.postValue(LiveEvent(FileTooBigError(tooBigFile.name
?: tooBigFile.path, tooBigFile.size, maxUploadFileSize)))
?: tooBigFile.path, tooBigFile.size, maxUploadFileSize)))
}
}
}
@ -647,6 +655,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)
@ -705,7 +714,8 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
.buffer(1, TimeUnit.SECONDS)
.filter { it.isNotEmpty() }
.subscribeBy(onNext = { actions ->
val bufferedMostRecentDisplayedEvent = actions.maxBy { it.event.displayIndex }?.event ?: return@subscribeBy
val bufferedMostRecentDisplayedEvent = actions.maxBy { it.event.displayIndex }?.event
?: return@subscribeBy
val globalMostRecentDisplayedEvent = mostRecentDisplayedEvent
if (trackUnreadMessages.get()) {
if (globalMostRecentDisplayedEvent == null) {
@ -775,19 +785,53 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}
private fun getSnapshotOfReadMarkerId() {
room.rx().liveRoomSummary()
.unwrap()
.filter { it.readMarkerId != null }
.take(1)
.subscribe { roomSummary ->
private fun getUnreadState() {
Observable
.combineLatest<List<TimelineEvent>, RoomSummary, UnreadState>(
timelineEvents,
room.rx().liveRoomSummary().unwrap(),
BiFunction { timelineEvents, roomSummary ->
computeUnreadState(timelineEvents, roomSummary)
}
)
.takeUntil {
it != UnreadState.Unknown
}
.subscribe { unreadState ->
setState {
copy(readMarkerIdSnapshot = roomSummary.readMarkerId)
copy(unreadState = unreadState)
}
}
.disposeOnClear()
}
private fun computeUnreadState(events: List<TimelineEvent>, roomSummary: RoomSummary): UnreadState {
val readMarkerIdSnapshot = roomSummary.readMarkerId ?: return UnreadState.Unknown
val firstDisplayableEventId = timeline.getFirstDisplayableEventId(readMarkerIdSnapshot)
?: return UnreadState.Unknown
val firstDisplayableEventIndex = timeline.getIndexOfEvent(firstDisplayableEventId)
?: return UnreadState.Unknown
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) {
@ -803,8 +847,14 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}
override fun onUpdated(snapshot: List<TimelineEvent>) {
timelineEvents.accept(snapshot)
setState { copy(currentSnapshot = snapshot) }
}
override fun onCleared() {
timeline.dispose()
timeline.removeAllListeners()
super.onCleared()
}
}

View File

@ -41,6 +41,12 @@ 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 HasUnread(val eventId: String) : UnreadState()
}
data class RoomDetailViewState(
val roomId: String,
val eventId: String?,
@ -53,7 +59,10 @@ data class RoomDetailViewState(
val tombstoneEventHandling: Async<String> = Uninitialized,
val syncState: SyncState = SyncState.IDLE,
val highlightedEventId: String? = null,
val readMarkerIdSnapshot: String? = null
val currentSnapshot: List<TimelineEvent> = emptyList(),
val hasMoreToLoadForward: Boolean = false,
val hasMoreToLoadBackward: Boolean = false,
val unreadState: UnreadState = UnreadState.Unknown
) : MvRxState {
constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId)

View File

@ -33,6 +33,7 @@ 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.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.*
@ -40,7 +41,6 @@ import im.vector.riotx.features.home.room.detail.timeline.item.*
import im.vector.riotx.features.media.ImageContentRenderer
import im.vector.riotx.features.media.VideoContentRenderer
import org.threeten.bp.LocalDateTime
import timber.log.Timber
import javax.inject.Inject
class TimelineEventController @Inject constructor(private val dateFormatter: VectorDateFormatter,
@ -82,7 +82,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
interface ReadReceiptsCallback {
fun onReadReceiptsClicked(readReceipts: List<ReadReceiptData>)
fun onReadMarkerDisplayed()
fun onReadMarkerVisible()
fun onReadMarkerInvisible()
}
interface UrlClickCallback {
@ -97,7 +98,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 readMarkerIdSnapshot: String? = null
private var unreadState: UnreadState = UnreadState.Unknown
private var positionOfReadMarker: Int? = null
private var eventIdToHighlight: String? = null
var callback: Callback? = null
@ -150,6 +153,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
// Update position when we are building new items
override fun intercept(models: MutableList<EpoxyModel<*>>) {
positionOfReadMarker = null
adapterPositionMapping.clear()
models.forEachIndexed { index, epoxyModel ->
if (epoxyModel is BaseEventItem) {
@ -157,19 +161,16 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
adapterPositionMapping[it] = index
}
}
if (epoxyModel is TimelineReadMarkerItem) {
positionOfReadMarker = index
}
}
}
fun update(viewState: RoomDetailViewState) {
if (timeline != viewState.timeline) {
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) {
@ -177,7 +178,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
synchronized(modelCache) {
for (i in 0 until modelCache.size) {
if (modelCache[i]?.eventId == viewState.highlightedEventId
|| modelCache[i]?.eventId == eventIdToHighlight) {
|| modelCache[i]?.eventId == eventIdToHighlight) {
modelCache[i] = null
}
}
@ -185,8 +186,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
eventIdToHighlight = viewState.highlightedEventId
requestModelBuild = true
}
if (this.readMarkerIdSnapshot != viewState.readMarkerIdSnapshot) {
this.readMarkerIdSnapshot = viewState.readMarkerIdSnapshot
if (this.unreadState != viewState.unreadState) {
this.unreadState = viewState.unreadState
requestModelBuild = true
}
if (requestModelBuild) {
@ -194,8 +195,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
}
}
private var eventIdToHighlight: String? = null
override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
timelineMediaSizeProvider.recyclerView = recyclerView
@ -250,7 +249,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
} else {
it.eventModel
}
listOf(eventModel, it?.mergedHeaderModel, it?.formattedDayModel, it?.readMarkerModel)
listOf(eventModel, it?.mergedHeaderModel, it?.readMarkerModel, it?.formattedDayModel)
}
.flatten()
.filterNotNull()
@ -260,31 +259,17 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
if (modelCache.isEmpty()) {
return
}
val displayableReadMarkerId = computeDisplayableReadMarkerId()
val currentUnreadState = this.unreadState
(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, displayableReadMarkerId)
if (modelCache[position] == null || modelCache[position]?.shouldTriggerBuild(currentUnreadState) == true) {
modelCache[position] = buildCacheItem(position, currentSnapshot, currentUnreadState)
}
}
}
private fun computeDisplayableReadMarkerId(): String? {
val readMarkerIdSnapshot = this.readMarkerIdSnapshot ?: return null
val firstDisplayableEventId = timeline?.getFirstDisplayableEventId(readMarkerIdSnapshot) ?: return null
val firstDisplayableEventIndex = timeline?.getIndexOfEvent(firstDisplayableEventId) ?: return null
for (i in (firstDisplayableEventIndex - 1) downTo 0) {
val timelineEvent = currentSnapshot.getOrNull(i) ?: return null
val isFromMe = timelineEvent.root.senderId == session.myUserId
if (!isFromMe) {
return timelineEvent.root.eventId
}
}
return null
}
private fun buildCacheItem(currentPosition: Int, items: List<TimelineEvent>, displayableReadMarkerId: String?): CacheItemData {
private fun buildCacheItem(currentPosition: Int, items: List<TimelineEvent>, currentUnreadState: UnreadState): CacheItemData {
val event = items[currentPosition]
val nextEvent = items.nextOrNull(currentPosition)
val date = event.root.localDateTime()
@ -295,29 +280,35 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
it.setOnVisibilityStateChanged(TimelineEventVisibilityStateChangedListener(callback, event))
}
val mergedHeaderModel = mergedHeaderItemFactory.create(event,
nextEvent = nextEvent,
items = items,
addDaySeparator = addDaySeparator,
currentPosition = currentPosition,
eventIdToHighlight = eventIdToHighlight,
callback = callback
nextEvent = nextEvent,
items = items,
addDaySeparator = addDaySeparator,
currentPosition = currentPosition,
eventIdToHighlight = eventIdToHighlight,
callback = callback
) {
requestModelBuild()
}
val daySeparatorItem = buildDaySeparatorItem(addDaySeparator, date)
val readMarkerItem = buildReadMarkerItem(event, displayableReadMarkerId)
val readMarkerItem = buildReadMarkerItem(event, currentUnreadState)
return CacheItemData(event.localId, event.root.eventId, eventModel, mergedHeaderModel, daySeparatorItem, readMarkerItem)
}
private fun buildReadMarkerItem(event: TimelineEvent, displayableReadMarkerId: String?): TimelineReadMarkerItem? {
return if (event.root.eventId == displayableReadMarkerId) {
TimelineReadMarkerItem_()
.also {
it.id("read_marker")
it.setOnVisibilityStateChanged(ReadMarkerVisibilityStateChangedListener(callback))
}
} else {
null
private fun buildReadMarkerItem(event: TimelineEvent, currentUnreadState: UnreadState): TimelineReadMarkerItem? {
return when (currentUnreadState) {
is UnreadState.HasUnread -> {
if (event.root.eventId == currentUnreadState.eventId) {
TimelineReadMarkerItem_()
.also {
it.id("read_marker")
it.setOnVisibilityStateChanged(ReadMarkerVisibilityStateChangedListener(callback))
}
} else {
null
}
}
UnreadState.Unknown,
UnreadState.HasNoUnread -> null
}
}
@ -354,6 +345,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(
@ -364,6 +359,6 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
val formattedDayModel: DaySeparatorItem? = null,
val readMarkerModel: TimelineReadMarkerItem? = null
) {
fun shouldTriggerBuild() = mergedHeaderModel != null || formattedDayModel != null
fun shouldTriggerBuild(unreadState: UnreadState) = mergedHeaderModel != null || formattedDayModel != null || readMarkerModel != null || (unreadState is UnreadState.HasUnread && unreadState.eventId == eventId)
}
}

View File

@ -24,12 +24,11 @@ import im.vector.riotx.features.home.room.detail.timeline.TimelineEventControlle
class ReadMarkerVisibilityStateChangedListener(private val callback: TimelineEventController.Callback?)
: VectorEpoxyModel.OnVisibilityStateChangedListener {
private var dispatched: Boolean = false
override fun onVisibilityStateChanged(visibilityState: Int) {
if (visibilityState == VisibilityState.VISIBLE && !dispatched) {
dispatched = true
callback?.onReadMarkerDisplayed()
if (visibilityState == VisibilityState.VISIBLE) {
callback?.onReadMarkerVisible()
} else if (visibilityState == VisibilityState.INVISIBLE) {
callback?.onReadMarkerInvisible()
}
}
}