diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt index c03effd7ad..a71f5b7479 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/timeline/Timeline.kt @@ -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 } + } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt index aa4bd42bf7..4411a039a5 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/DefaultTimeline.kt @@ -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() 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): 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) } } diff --git a/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt index abc2dd98f8..5ba482837e 100644 --- a/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt +++ b/vector/src/main/java/im/vector/riotx/core/ui/views/JumpToReadMarkerView.kt @@ -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 - } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt deleted file mode 100644 index 98556cc7fa..0000000000 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/ReadMarkerHelper.kt +++ /dev/null @@ -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?) - } -} diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index e0c43b9e74..8fb4d30b9f 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -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() { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index e1ff991797..ccce70f33c 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -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(initialState) { +) : VectorViewModel(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() 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>() 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 {}) @@ -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() - ?: 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()?.relatesTo?.inReplyTo?.eventId - ?: state.sendMode.timelineEvent.root.content.toModel()?.relatesTo?.inReplyTo?.eventId + ?: state.sendMode.timelineEvent.root.content.toModel()?.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, 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, 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) { + timelineEvents.accept(snapshot) + setState { copy(currentSnapshot = snapshot) } + } + override fun onCleared() { timeline.dispose() + timeline.removeAllListeners() super.onCleared() } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt index e476545aa8..23971a93cd 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewState.kt @@ -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 = Uninitialized, val syncState: SyncState = SyncState.IDLE, val highlightedEventId: String? = null, - val readMarkerIdSnapshot: String? = null + val currentSnapshot: List = 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) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index 180deb998b..d4447f5b05 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -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) - fun onReadMarkerDisplayed() + fun onReadMarkerVisible() + fun onReadMarkerInvisible() } interface UrlClickCallback { @@ -97,7 +98,9 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec private var currentSnapshot: List = 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>) { + 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, displayableReadMarkerId: String?): CacheItemData { + private fun buildCacheItem(currentPosition: Int, items: List, 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) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineVisibilityStateChangedListeners.kt similarity index 91% rename from vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt rename to vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineVisibilityStateChangedListeners.kt index 7efbce0073..11b7d68923 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineEventVisibilityStateChangedListener.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/TimelineVisibilityStateChangedListeners.kt @@ -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() } } }