ReadMarker: extract from ViewModel the jump to read marker visibility logic as it's easier to deal with.

This commit is contained in:
ganfra 2019-09-24 12:57:32 +02:00
parent 05d09bf950
commit c6d01fbcf4
6 changed files with 140 additions and 98 deletions

View File

@ -0,0 +1,75 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.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 timber.log.Timber
import javax.inject.Inject
@ScreenScope
class ReadMarkerHelper @Inject constructor() {
lateinit var timelineEventController: TimelineEventController
lateinit var layoutManager: LinearLayoutManager
var callback: Callback? = null
private var state: RoomDetailViewState? = null
fun updateState(state: RoomDetailViewState) {
this.state = state
checkJumpToReadMarkerVisibility()
}
fun onTimelineScrolled() {
checkJumpToReadMarkerVisibility()
}
private fun checkJumpToReadMarkerVisibility() {
val nonNullState = this.state ?: return
val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
val readMarkerId = nonNullState.asyncRoomSummary()?.readMarkerId
if (readMarkerId == null) {
callback?.onVisibilityUpdated(false, null)
}
val positionOfReadMarker = timelineEventController.searchPositionOfEvent(readMarkerId)
Timber.v("Position of readMarker: $positionOfReadMarker")
Timber.v("Position of lastVisibleItem: $lastVisibleItem")
if (positionOfReadMarker == null) {
if (nonNullState.timeline?.isLive == true && lastVisibleItem > 0) {
callback?.onVisibilityUpdated(true, readMarkerId)
} else {
callback?.onVisibilityUpdated(false, readMarkerId)
}
} else {
if (positionOfReadMarker > lastVisibleItem) {
callback?.onVisibilityUpdated(true, readMarkerId)
} else {
callback?.onVisibilityUpdated(false, readMarkerId)
}
}
}
interface Callback {
fun onVisibilityUpdated(show: Boolean, readMarkerId: String?)
}
}

View File

@ -227,6 +227,7 @@ class RoomDetailFragment :
@Inject lateinit var errorFormatter: ErrorFormatter @Inject lateinit var errorFormatter: ErrorFormatter
@Inject lateinit var eventHtmlRenderer: EventHtmlRenderer @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer
@Inject lateinit var vectorPreferences: VectorPreferences @Inject lateinit var vectorPreferences: VectorPreferences
@Inject lateinit var readMarkerHelper: ReadMarkerHelper
private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback
private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback
@ -398,22 +399,22 @@ 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
updateComposerText(defaultContent) updateComposerText(defaultContent)
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)
avatarRenderer.render(event.senderAvatar, avatarRenderer.render(event.senderAvatar,
event.root.senderId ?: "", event.root.senderId ?: "",
event.senderName, event.senderName,
composerLayout.composerRelatedMessageAvatar) composerLayout.composerRelatedMessageAvatar)
composerLayout.expand { composerLayout.expand {
//need to do it here also when not using quick reply //need to do it here also when not using quick reply
focusComposerAndShowKeyboard() focusComposerAndShowKeyboard()
@ -451,9 +452,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))
} }
@ -479,6 +480,13 @@ class RoomDetailFragment :
it.dispatchTo(scrollOnNewMessageCallback) it.dispatchTo(scrollOnNewMessageCallback)
it.dispatchTo(scrollOnHighlightedEventCallback) it.dispatchTo(scrollOnHighlightedEventCallback)
} }
readMarkerHelper.timelineEventController = timelineEventController
readMarkerHelper.layoutManager = layoutManager
readMarkerHelper.callback = object : ReadMarkerHelper.Callback {
override fun onVisibilityUpdated(show: Boolean, readMarkerId: String?) {
jumpToReadMarkerView.render(show, readMarkerId)
}
}
recyclerView.setController(timelineEventController) recyclerView.setController(timelineEventController)
recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
@ -486,6 +494,7 @@ class RoomDetailFragment :
if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) { if (recyclerView.scrollState == RecyclerView.SCROLL_STATE_IDLE) {
updateJumpToBottomViewVisibility() updateJumpToBottomViewVisibility()
} }
readMarkerHelper.onTimelineScrolled()
} }
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
@ -504,26 +513,26 @@ 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, composerLayout.composerEditText.text.toString())) roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId, composerLayout.composerEditText.text.toString()))
} }
} }
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)
} }
@ -698,6 +707,7 @@ class RoomDetailFragment :
} }
private fun renderState(state: RoomDetailViewState) { private fun renderState(state: RoomDetailViewState) {
readMarkerHelper.updateState(state)
renderRoomSummary(state) renderRoomSummary(state)
val summary = state.asyncRoomSummary() val summary = state.asyncRoomSummary()
val inviter = state.asyncInviter() val inviter = state.asyncInviter()
@ -726,7 +736,6 @@ class RoomDetailFragment :
composerLayout.visibility = View.GONE composerLayout.visibility = View.GONE
notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent)) notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent))
} }
jumpToReadMarkerView.render(state.showJumpToReadMarker, summary?.readMarkerId)
} }
private fun renderRoomSummary(state: RoomDetailViewState) { private fun renderRoomSummary(state: RoomDetailViewState) {
@ -1151,7 +1160,7 @@ class RoomDetailFragment :
roomDetailViewModel.process(RoomDetailActions.RejectInvite) roomDetailViewModel.process(RoomDetailActions.RejectInvite)
} }
// JumpToReadMarkerView.Callback // JumpToReadMarkerView.Callback
override fun onJumpToReadMarkerClicked(readMarkerId: String) { override fun onJumpToReadMarkerClicked(readMarkerId: String) {
roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(readMarkerId, false)) roomDetailViewModel.process(RoomDetailActions.NavigateToEvent(readMarkerId, false))

View File

@ -119,7 +119,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
observeRoomSummary() observeRoomSummary()
observeEventDisplayedActions() observeEventDisplayedActions()
observeSummaryState() observeSummaryState()
observeJumpToReadMarkerViewVisibility()
observeReadMarkerVisibility() observeReadMarkerVisibility()
observeDrafts() observeDrafts()
room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear() room.rx().loadRoomMembersIfNeeded().subscribeLogError().disposeOnClear()
@ -185,23 +184,23 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
copy( copy(
// Create a sendMode from a draft and retrieve the TimelineEvent // Create a sendMode from a draft and retrieve the TimelineEvent
sendMode = when (draft) { sendMode = when (draft) {
is UserDraft.REGULAR -> SendMode.REGULAR(draft.text) is UserDraft.REGULAR -> SendMode.REGULAR(draft.text)
is UserDraft.QUOTE -> { is UserDraft.QUOTE -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.QUOTE(timelineEvent, draft.text) SendMode.QUOTE(timelineEvent, draft.text)
} }
} }
is UserDraft.REPLY -> { is UserDraft.REPLY -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.REPLY(timelineEvent, draft.text) SendMode.REPLY(timelineEvent, draft.text)
} }
} }
is UserDraft.EDIT -> { is UserDraft.EDIT -> {
room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent -> room.getTimeLineEvent(draft.linkedEventId)?.let { timelineEvent ->
SendMode.EDIT(timelineEvent, draft.text) SendMode.EDIT(timelineEvent, draft.text)
} }
} }
} ?: SendMode.REGULAR("") } ?: SendMode.REGULAR("")
) )
} }
} }
@ -210,7 +209,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
@ -345,7 +344,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is SendMode.EDIT -> { is SendMode.EDIT -> {
//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 {
@ -354,13 +353,13 @@ 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 ?: MessageType.MSGTYPE_TEXT, messageContent?.type ?: MessageType.MSGTYPE_TEXT,
action.text, action.text,
action.autoMarkdown) action.autoMarkdown)
} else { } else {
Timber.w("Same message content, do not send edition") Timber.w("Same message content, do not send edition")
} }
@ -371,7 +370,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)
@ -702,45 +701,6 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
.disposeOnClear() .disposeOnClear()
} }
private fun observeJumpToReadMarkerViewVisibility() {
Observable.combineLatest(
room.rx().liveRoomSummary()
.map {
val readMarkerId = it.readMarkerId
if (readMarkerId == null) {
Option.empty()
} else {
val readMarkerIndex = room.getTimeLineEvent(readMarkerId)?.displayIndex
?: Int.MIN_VALUE
Option.just(readMarkerIndex)
}
}
.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()
.subscribe {
setState { copy(showJumpToReadMarker = it) }
}
.disposeOnClear()
}
private fun isEventVisibleObservable(filterEvent: (TimelineEvent) -> Boolean): Observable<Boolean> {
return Observable.merge(
visibleEventsObservable.filter { filterEvent(it.event) }.map { true },
invisibleEventsObservable.filter { filterEvent(it.event) }.map { false }
)
}
private fun observeRoomSummary() { private fun observeRoomSummary() {
room.rx().liveRoomSummary() room.rx().liveRoomSummary()
.execute { async -> .execute { async ->

View File

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

View File

@ -56,7 +56,6 @@ class ScrollOnHighlightedEventCallback(private val layoutManager: LinearLayoutMa
// Do not scroll it item is already visible // Do not scroll it item is already visible
if (positionToScroll !in firstVisibleItem..lastVisibleItem) { if (positionToScroll !in firstVisibleItem..lastVisibleItem) {
Timber.v("Scroll to $positionToScroll") Timber.v("Scroll to $positionToScroll")
// Note: Offset will be from the bottom, since the layoutManager is reversed
layoutManager.scrollToPosition(positionToScroll) layoutManager.scrollToPosition(positionToScroll)
} }
scheduledEventId.set(null) scheduledEventId.set(null)

View File

@ -158,7 +158,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
synchronized(modelCache) { synchronized(modelCache) {
for (i in 0 until modelCache.size) { for (i in 0 until modelCache.size) {
if (modelCache[i]?.eventId == viewState.highlightedEventId if (modelCache[i]?.eventId == viewState.highlightedEventId
|| modelCache[i]?.eventId == eventIdToHighlight) { || modelCache[i]?.eventId == eventIdToHighlight) {
modelCache[i] = null modelCache[i] = null
} }
} }
@ -229,8 +229,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)
} }
} }