Merge branch 'develop' into feature/sync_issues

This commit is contained in:
ganfra 2019-12-04 16:25:35 +01:00
commit 990a266d83
62 changed files with 630 additions and 871 deletions

View File

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

View File

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

View File

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

View File

@ -23,7 +23,6 @@ import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.mapper.toEntity import im.vector.matrix.android.internal.database.mapper.toEntity
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.EventAnnotationsSummaryEntity import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity
import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptEntity import im.vector.matrix.android.internal.database.model.ReadReceiptEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity
@ -113,7 +112,6 @@ internal fun ChunkEntity.add(localRealm: Realm,
it.roomId = roomId it.roomId = roomId
it.annotations = EventAnnotationsSummaryEntity.where(localRealm, eventId).findFirst() it.annotations = EventAnnotationsSummaryEntity.where(localRealm, eventId).findFirst()
it.readReceipts = readReceiptsSummaryEntity it.readReceipts = readReceiptsSummaryEntity
it.readMarker = ReadMarkerEntity.where(localRealm, roomId = roomId, eventId = eventId).findFirst()
} }
eventEntity.updateSenderData(localRealm, this) eventEntity.updateSenderData(localRealm, this)
val position = if (direction == PaginationDirection.FORWARDS) 0 else this.timelineEvents.size val position = if (direction == PaginationDirection.FORWARDS) 0 else this.timelineEvents.size
@ -122,14 +120,14 @@ internal fun ChunkEntity.add(localRealm: Realm,
internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int { internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) { return when (direction) {
PaginationDirection.FORWARDS -> forwardsDisplayIndex PaginationDirection.FORWARDS -> forwardsDisplayIndex
PaginationDirection.BACKWARDS -> backwardsDisplayIndex PaginationDirection.BACKWARDS -> backwardsDisplayIndex
} ?: defaultValue } ?: defaultValue
} }
internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int { internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) { return when (direction) {
PaginationDirection.FORWARDS -> forwardsStateIndex PaginationDirection.FORWARDS -> forwardsStateIndex
PaginationDirection.BACKWARDS -> backwardsStateIndex PaginationDirection.BACKWARDS -> backwardsStateIndex
} ?: defaultValue } ?: defaultValue
} }

View File

@ -46,8 +46,7 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS
senderAvatar = timelineEventEntity.senderAvatar, senderAvatar = timelineEventEntity.senderAvatar,
readReceipts = readReceipts?.sortedByDescending { readReceipts = readReceipts?.sortedByDescending {
it.originServerTs it.originServerTs
} ?: emptyList(), } ?: emptyList()
hasReadMarker = timelineEventEntity.readMarker?.eventId?.isNotEmpty() == true
) )
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -62,10 +62,14 @@ internal class TextPillsUtils @Inject constructor(
var currIndex = 0 var currIndex = 0
pills.forEachIndexed { _, (urlSpan, start, end) -> pills.forEachIndexed { _, (urlSpan, start, end) ->
// We want to replace with the pill with a html link // We want to replace with the pill with a html link
// append text before pill
append(text, currIndex, start) append(text, currIndex, start)
// append the pill
append(String.format(template, urlSpan.userId, urlSpan.displayName)) append(String.format(template, urlSpan.userId, urlSpan.displayName))
currIndex = end currIndex = end
} }
// append text after the last pill
append(text, currIndex, text.length)
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,133 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.room.timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
import im.vector.matrix.android.internal.database.model.ReadMarkerEntity
import im.vector.matrix.android.internal.database.model.ReadMarkerEntityFields
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields
import im.vector.matrix.android.internal.database.query.FilterContent
import im.vector.matrix.android.internal.database.query.where
import io.realm.OrderedRealmCollectionChangeListener
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.RealmResults
/**
* This class is responsible for handling the read marker for hidden events.
* When an hidden event has read marker, we want to transfer it on the first older displayed event.
* It has to be used in [DefaultTimeline] and we should call the [start] and [dispose] methods to properly handle realm subscription.
*/
internal class TimelineHiddenReadMarker constructor(private val roomId: String,
private val settings: TimelineSettings) {
interface Delegate {
fun rebuildEvent(eventId: String, hasReadMarker: Boolean): Boolean
fun onReadMarkerUpdated()
}
private var previousDisplayedEventId: String? = null
private var hiddenReadMarker: RealmResults<ReadMarkerEntity>? = null
private lateinit var filteredEvents: RealmResults<TimelineEventEntity>
private lateinit var nonFilteredEvents: RealmResults<TimelineEventEntity>
private lateinit var delegate: Delegate
private val readMarkerListener = OrderedRealmCollectionChangeListener<RealmResults<ReadMarkerEntity>> { readMarkers, changeSet ->
if (!readMarkers.isLoaded || !readMarkers.isValid) {
return@OrderedRealmCollectionChangeListener
}
var hasChange = false
if (changeSet.deletions.isNotEmpty()) {
previousDisplayedEventId?.also {
hasChange = delegate.rebuildEvent(it, false)
previousDisplayedEventId = null
}
}
val readMarker = readMarkers.firstOrNull() ?: return@OrderedRealmCollectionChangeListener
val hiddenEvent = readMarker.timelineEvent?.firstOrNull()
?: return@OrderedRealmCollectionChangeListener
val isLoaded = nonFilteredEvents.where()
.equalTo(TimelineEventEntityFields.EVENT_ID, hiddenEvent.eventId)
.findFirst() != null
val displayIndex = hiddenEvent.root?.displayIndex
if (isLoaded && displayIndex != null) {
// Then we are looking for the first displayable event after the hidden one
val firstDisplayedEvent = filteredEvents.where()
.lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex)
.findFirst()
// If we find one, we should rebuild this one with marker
if (firstDisplayedEvent != null) {
previousDisplayedEventId = firstDisplayedEvent.eventId
hasChange = delegate.rebuildEvent(firstDisplayedEvent.eventId, true)
}
}
if (hasChange) {
delegate.onReadMarkerUpdated()
}
}
/**
* Start the realm query subscription. Has to be called on an HandlerThread
*/
fun start(realm: Realm,
filteredEvents: RealmResults<TimelineEventEntity>,
nonFilteredEvents: RealmResults<TimelineEventEntity>,
delegate: Delegate) {
this.filteredEvents = filteredEvents
this.nonFilteredEvents = nonFilteredEvents
this.delegate = delegate
// We are looking for read receipts set on hidden events.
// We only accept those with a timelineEvent (so coming from pagination/sync).
hiddenReadMarker = ReadMarkerEntity.where(realm, roomId = roomId)
.isNotEmpty(ReadMarkerEntityFields.TIMELINE_EVENT)
.filterReceiptsWithSettings()
.findAllAsync()
.also { it.addChangeListener(readMarkerListener) }
}
/**
* Dispose the realm query subscription. Has to be called on an HandlerThread
*/
fun dispose() {
this.hiddenReadMarker?.removeAllChangeListeners()
}
/**
* We are looking for readMarker related to filtered events. So, it's the opposite of [DefaultTimeline.filterEventsWithSettings] method.
*/
private fun RealmQuery<ReadMarkerEntity>.filterReceiptsWithSettings(): RealmQuery<ReadMarkerEntity> {
beginGroup()
if (settings.filterTypes) {
not().`in`("${ReadMarkerEntityFields.TIMELINE_EVENT}.${TimelineEventEntityFields.ROOT.TYPE}", settings.allowedTypes.toTypedArray())
}
if (settings.filterTypes && settings.filterEdits) {
or()
}
if (settings.filterEdits) {
like("${ReadMarkerEntityFields.TIMELINE_EVENT}.${TimelineEventEntityFields.ROOT.CONTENT}", FilterContent.EDIT_TYPE)
}
endGroup()
return this
}
}

View File

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

View File

@ -29,7 +29,7 @@ class SasEmojiController : TypedEpoxyController<SasState>() {
if (data == null) return if (data == null) return
data.emojiList.forEachIndexed { idx, emojiRepresentation -> data.emojiList.forEachIndexed { idx, emojiRepresentation ->
itemSasEmoji { sasEmojiItem {
id(idx) id(idx)
index(idx) index(idx)
emojiRepresentation(emojiRepresentation) emojiRepresentation(emojiRepresentation)

View File

@ -25,7 +25,7 @@ import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.epoxy.VectorEpoxyModel
@EpoxyModelClass(layout = im.vector.riotx.R.layout.item_sas_emoji) @EpoxyModelClass(layout = im.vector.riotx.R.layout.item_sas_emoji)
abstract class ItemSasEmoji : VectorEpoxyModel<ItemSasEmoji.Holder>() { abstract class SasEmojiItem : VectorEpoxyModel<SasEmojiItem.Holder>() {
@EpoxyAttribute @EpoxyAttribute
var index: Int = 0 var index: Int = 0

View File

@ -37,7 +37,7 @@ import im.vector.riotx.features.themes.ThemeUtils
* A action for bottom sheet. * A action for bottom sheet.
*/ */
@EpoxyModelClass(layout = R.layout.item_bottom_sheet_action) @EpoxyModelClass(layout = R.layout.item_bottom_sheet_action)
abstract class BottomSheetItemAction : VectorEpoxyModel<BottomSheetItemAction.Holder>() { abstract class BottomSheetActionItem : VectorEpoxyModel<BottomSheetActionItem.Holder>() {
@EpoxyAttribute @EpoxyAttribute
@DrawableRes @DrawableRes

View File

@ -16,6 +16,7 @@
*/ */
package im.vector.riotx.core.epoxy.bottomsheet package im.vector.riotx.core.epoxy.bottomsheet
import android.text.method.MovementMethod
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
@ -25,12 +26,13 @@ import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.extensions.setTextOrHide import im.vector.riotx.core.extensions.setTextOrHide
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.tools.findPillsAndProcess
/** /**
* A message preview for bottom sheet. * A message preview for bottom sheet.
*/ */
@EpoxyModelClass(layout = R.layout.item_bottom_sheet_message_preview) @EpoxyModelClass(layout = R.layout.item_bottom_sheet_message_preview)
abstract class BottomSheetItemMessagePreview : VectorEpoxyModel<BottomSheetItemMessagePreview.Holder>() { abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessagePreviewItem.Holder>() {
@EpoxyAttribute @EpoxyAttribute
lateinit var avatarRenderer: AvatarRenderer lateinit var avatarRenderer: AvatarRenderer
@ -44,11 +46,15 @@ abstract class BottomSheetItemMessagePreview : VectorEpoxyModel<BottomSheetItemM
lateinit var body: CharSequence lateinit var body: CharSequence
@EpoxyAttribute @EpoxyAttribute
var time: CharSequence? = null var time: CharSequence? = null
@EpoxyAttribute
var movementMethod: MovementMethod? = null
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
avatarRenderer.render(avatarUrl, senderId, senderName, holder.avatar) avatarRenderer.render(avatarUrl, senderId, senderName, holder.avatar)
holder.sender.setTextOrHide(senderName) holder.sender.setTextOrHide(senderName)
holder.body.movementMethod = movementMethod
holder.body.text = body holder.body.text = body
body.findPillsAndProcess { it.bind(holder.body) }
holder.timestamp.setTextOrHide(time) holder.timestamp.setTextOrHide(time)
} }

View File

@ -29,7 +29,7 @@ import im.vector.riotx.core.epoxy.VectorEpoxyModel
* A quick reaction list for bottom sheet. * A quick reaction list for bottom sheet.
*/ */
@EpoxyModelClass(layout = R.layout.item_bottom_sheet_quick_reaction) @EpoxyModelClass(layout = R.layout.item_bottom_sheet_quick_reaction)
abstract class BottomSheetItemQuickReactions : VectorEpoxyModel<BottomSheetItemQuickReactions.Holder>() { abstract class BottomSheetQuickReactionsItem : VectorEpoxyModel<BottomSheetQuickReactionsItem.Holder>() {
@EpoxyAttribute @EpoxyAttribute
lateinit var fontProvider: EmojiCompatFontProvider lateinit var fontProvider: EmojiCompatFontProvider

View File

@ -31,7 +31,7 @@ import im.vector.riotx.features.home.AvatarRenderer
* A room preview for bottom sheet. * A room preview for bottom sheet.
*/ */
@EpoxyModelClass(layout = R.layout.item_bottom_sheet_room_preview) @EpoxyModelClass(layout = R.layout.item_bottom_sheet_room_preview)
abstract class BottomSheetItemRoomPreview : VectorEpoxyModel<BottomSheetItemRoomPreview.Holder>() { abstract class BottomSheetRoomPreviewItem : VectorEpoxyModel<BottomSheetRoomPreviewItem.Holder>() {
@EpoxyAttribute @EpoxyAttribute
lateinit var avatarRenderer: AvatarRenderer lateinit var avatarRenderer: AvatarRenderer

View File

@ -30,7 +30,7 @@ import im.vector.riotx.core.epoxy.VectorEpoxyModel
* A send state for bottom sheet. * A send state for bottom sheet.
*/ */
@EpoxyModelClass(layout = R.layout.item_bottom_sheet_message_status) @EpoxyModelClass(layout = R.layout.item_bottom_sheet_message_status)
abstract class BottomSheetItemSendState : VectorEpoxyModel<BottomSheetItemSendState.Holder>() { abstract class BottomSheetSendStateItem : VectorEpoxyModel<BottomSheetSendStateItem.Holder>() {
@EpoxyAttribute @EpoxyAttribute
var showProgress: Boolean = false var showProgress: Boolean = false

View File

@ -22,7 +22,7 @@ import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.epoxy.VectorEpoxyModel
@EpoxyModelClass(layout = R.layout.item_bottom_sheet_divider) @EpoxyModelClass(layout = R.layout.item_bottom_sheet_divider)
abstract class BottomSheetItemSeparator : VectorEpoxyModel<BottomSheetItemSeparator.Holder>() { abstract class BottomSheetSeparatorItem : VectorEpoxyModel<BottomSheetSeparatorItem.Holder>() {
class Holder : VectorEpoxyHolder() class Holder : VectorEpoxyHolder()
} }

View File

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

View File

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

View File

@ -1,89 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.core.ui.views
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.animation.Animation
import android.view.animation.AnimationUtils
import im.vector.riotx.R
import kotlinx.coroutines.*
private const val DELAY_IN_MS = 1_000L
class ReadMarkerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
interface Callback {
fun onReadMarkerLongBound(isDisplayed: Boolean)
}
private var eventId: String? = null
private var callback: Callback? = null
private var callbackDispatcherJob: Job? = null
fun bindView(eventId: String?, hasReadMarker: Boolean, displayReadMarker: Boolean, readMarkerCallback: Callback) {
this.eventId = eventId
this.callback = readMarkerCallback
if (displayReadMarker) {
startAnimation()
} else {
this.animation?.cancel()
this.visibility = INVISIBLE
}
if (hasReadMarker) {
callbackDispatcherJob = GlobalScope.launch(Dispatchers.Main) {
delay(DELAY_IN_MS)
callback?.onReadMarkerLongBound(displayReadMarker)
}
}
}
fun unbind() {
this.callbackDispatcherJob?.cancel()
this.callback = null
this.eventId = null
this.animation?.cancel()
this.visibility = INVISIBLE
}
private fun startAnimation() {
if (animation == null) {
animation = AnimationUtils.loadAnimation(context, R.anim.unread_marker_anim)
animation.startOffset = DELAY_IN_MS / 2
animation.duration = DELAY_IN_MS / 2
animation.setAnimationListener(object : Animation.AnimationListener {
override fun onAnimationStart(animation: Animation) {
}
override fun onAnimationEnd(animation: Animation) {
visibility = INVISIBLE
}
override fun onAnimationRepeat(animation: Animation) {}
})
}
visibility = VISIBLE
animation.start()
}
}

View File

@ -1,99 +0,0 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail
import androidx.recyclerview.widget.LinearLayoutManager
import im.vector.riotx.core.di.ScreenScope
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import javax.inject.Inject
@ScreenScope
class ReadMarkerHelper @Inject constructor() {
lateinit var timelineEventController: TimelineEventController
lateinit var layoutManager: LinearLayoutManager
var callback: Callback? = null
private var onReadMarkerLongDisplayed = false
private var jumpToReadMarkerVisible = false
private var readMarkerVisible: Boolean = true
private var state: RoomDetailViewState? = null
fun readMarkerVisible(): Boolean {
return readMarkerVisible
}
fun onResume() {
onReadMarkerLongDisplayed = false
}
fun onReadMarkerLongDisplayed() {
onReadMarkerLongDisplayed = true
}
fun updateWith(newState: RoomDetailViewState) {
state = newState
checkReadMarkerVisibility()
checkJumpToReadMarkerVisibility()
}
fun onTimelineScrolled() {
checkJumpToReadMarkerVisibility()
}
private fun checkReadMarkerVisibility() {
val nonNullState = this.state ?: return
val firstVisibleItem = layoutManager.findFirstVisibleItemPosition()
val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
readMarkerVisible = if (!onReadMarkerLongDisplayed) {
true
} else {
if (nonNullState.timeline?.isLive == false) {
true
} else {
!(firstVisibleItem == 0 && lastVisibleItem > 0)
}
}
}
private fun checkJumpToReadMarkerVisibility() {
val nonNullState = this.state ?: return
val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
val readMarkerId = nonNullState.asyncRoomSummary()?.readMarkerId
val newJumpToReadMarkerVisible = if (readMarkerId == null) {
false
} else {
val correctedReadMarkerId = nonNullState.timeline?.getFirstDisplayableEventId(readMarkerId)
?: readMarkerId
val positionOfReadMarker = timelineEventController.searchPositionOfEvent(correctedReadMarkerId)
if (positionOfReadMarker == null) {
nonNullState.timeline?.isLive == true && lastVisibleItem > 0
} else {
positionOfReadMarker > lastVisibleItem
}
}
if (newJumpToReadMarkerVisible != jumpToReadMarkerVisible) {
jumpToReadMarkerVisible = newJumpToReadMarkerVisible
callback?.onJumpToReadMarkerVisibilityUpdate(jumpToReadMarkerVisible, readMarkerId)
}
}
interface Callback {
fun onJumpToReadMarkerVisibilityUpdate(show: Boolean, readMarkerId: String?)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -66,7 +66,7 @@ class DisplayReadReceiptsBottomSheet : VectorBaseBottomSheetDialogFragment() {
super.onActivityCreated(savedInstanceState) super.onActivityCreated(savedInstanceState)
recyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) recyclerView.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
recyclerView.adapter = epoxyController.adapter recyclerView.adapter = epoxyController.adapter
bottomSheetTitle.text = getString(R.string.read_at) bottomSheetTitle.text = getString(R.string.seen_by)
epoxyController.setData(displayReadReceiptArgs.readReceipts) epoxyController.setData(displayReadReceiptArgs.readReceipts)
} }

View File

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

View File

@ -88,4 +88,12 @@ sealed class EventSharedAction(@StringRes val titleRes: Int, @DrawableRes val ic
data class ViewEditHistory(val messageInformationData: MessageInformationData) : data class ViewEditHistory(val messageInformationData: MessageInformationData) :
EventSharedAction(R.string.message_view_edit_history, R.drawable.ic_view_edit_history) EventSharedAction(R.string.message_view_edit_history, R.drawable.ic_view_edit_history)
// An url in the event preview has been clicked
data class OnUrlClicked(val url: String) :
EventSharedAction(0, 0)
// An url in the event preview has been long clicked
data class OnUrlLongClicked(val url: String) :
EventSharedAction(0, 0)
} }

View File

@ -68,6 +68,18 @@ class MessageActionsBottomSheet : VectorBaseBottomSheetDialogFragment(), Message
messageActionsEpoxyController.listener = this messageActionsEpoxyController.listener = this
} }
override fun onUrlClicked(url: String): Boolean {
sharedActionViewModel.post(EventSharedAction.OnUrlClicked(url))
// Always consume
return true
}
override fun onUrlLongClicked(url: String): Boolean {
sharedActionViewModel.post(EventSharedAction.OnUrlLongClicked(url))
// Always consume
return true
}
override fun didSelectMenuAction(eventAction: EventSharedAction) { override fun didSelectMenuAction(eventAction: EventSharedAction) {
if (eventAction is EventSharedAction.ReportContent) { if (eventAction is EventSharedAction.ReportContent) {
// Toggle report menu // Toggle report menu

View File

@ -23,6 +23,9 @@ import im.vector.riotx.R
import im.vector.riotx.core.epoxy.bottomsheet.* import im.vector.riotx.core.epoxy.bottomsheet.*
import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.tools.createLinkMovementMethod
import im.vector.riotx.features.home.room.detail.timeline.tools.linkify
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -38,26 +41,27 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid
// Message preview // Message preview
val body = state.messageBody val body = state.messageBody
if (body != null) { if (body != null) {
bottomSheetItemMessagePreview { bottomSheetMessagePreviewItem {
id("preview") id("preview")
avatarRenderer(avatarRenderer) avatarRenderer(avatarRenderer)
avatarUrl(state.informationData.avatarUrl ?: "") avatarUrl(state.informationData.avatarUrl ?: "")
senderId(state.informationData.senderId) senderId(state.informationData.senderId)
senderName(state.senderName()) senderName(state.senderName())
body(body) movementMethod(createLinkMovementMethod(listener))
body(body.linkify(listener))
time(state.time()) time(state.time())
} }
} }
// Send state // Send state
if (state.informationData.sendState.isSending()) { if (state.informationData.sendState.isSending()) {
bottomSheetItemSendState { bottomSheetSendStateItem {
id("send_state") id("send_state")
showProgress(true) showProgress(true)
text(stringProvider.getString(R.string.event_status_sending_message)) text(stringProvider.getString(R.string.event_status_sending_message))
} }
} else if (state.informationData.sendState.hasFailed()) { } else if (state.informationData.sendState.hasFailed()) {
bottomSheetItemSendState { bottomSheetSendStateItem {
id("send_state") id("send_state")
showProgress(false) showProgress(false)
text(stringProvider.getString(R.string.unable_to_send_message)) text(stringProvider.getString(R.string.unable_to_send_message))
@ -68,16 +72,16 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid
// Quick reactions // Quick reactions
if (state.canReact() && state.quickStates is Success) { if (state.canReact() && state.quickStates is Success) {
// Separator // Separator
bottomSheetItemSeparator { bottomSheetSeparatorItem {
id("reaction_separator") id("reaction_separator")
} }
bottomSheetItemQuickReactions { bottomSheetQuickReactionsItem {
id("quick_reaction") id("quick_reaction")
fontProvider(fontProvider) fontProvider(fontProvider)
texts(state.quickStates()?.map { it.reaction }.orEmpty()) texts(state.quickStates()?.map { it.reaction }.orEmpty())
selecteds(state.quickStates.invoke().map { it.isSelected }) selecteds(state.quickStates.invoke().map { it.isSelected })
listener(object : BottomSheetItemQuickReactions.Listener { listener(object : BottomSheetQuickReactionsItem.Listener {
override fun didSelect(emoji: String, selected: Boolean) { override fun didSelect(emoji: String, selected: Boolean) {
listener?.didSelectMenuAction(EventSharedAction.QuickReact(state.eventId, emoji, selected)) listener?.didSelectMenuAction(EventSharedAction.QuickReact(state.eventId, emoji, selected))
} }
@ -86,18 +90,18 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid
} }
// Separator // Separator
bottomSheetItemSeparator { bottomSheetSeparatorItem {
id("actions_separator") id("actions_separator")
} }
// Action // Action
state.actions()?.forEachIndexed { index, action -> state.actions()?.forEachIndexed { index, action ->
if (action is EventSharedAction.Separator) { if (action is EventSharedAction.Separator) {
bottomSheetItemSeparator { bottomSheetSeparatorItem {
id("separator_$index") id("separator_$index")
} }
} else { } else {
bottomSheetItemAction { bottomSheetActionItem {
id("action_$index") id("action_$index")
iconRes(action.iconResId) iconRes(action.iconResId)
textRes(action.titleRes) textRes(action.titleRes)
@ -114,7 +118,7 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid
EventSharedAction.ReportContentInappropriate(action.eventId, action.senderId), EventSharedAction.ReportContentInappropriate(action.eventId, action.senderId),
EventSharedAction.ReportContentCustom(action.eventId, action.senderId) EventSharedAction.ReportContentCustom(action.eventId, action.senderId)
).forEachIndexed { indexReport, actionReport -> ).forEachIndexed { indexReport, actionReport ->
bottomSheetItemAction { bottomSheetActionItem {
id("actionReport_$indexReport") id("actionReport_$indexReport")
subMenuItem(true) subMenuItem(true)
iconRes(actionReport.iconResId) iconRes(actionReport.iconResId)
@ -127,7 +131,7 @@ class MessageActionsEpoxyController @Inject constructor(private val stringProvid
} }
} }
interface MessageActionsEpoxyControllerListener { interface MessageActionsEpoxyControllerListener : TimelineEventController.UrlClickCallback {
fun didSelectMenuAction(eventAction: EventSharedAction) fun didSelectMenuAction(eventAction: EventSharedAction)
} }
} }

View File

@ -46,7 +46,6 @@ class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: Ava
fun create(event: TimelineEvent, fun create(event: TimelineEvent,
highlight: Boolean, highlight: Boolean,
readMarkerVisible: 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) {
@ -54,7 +53,7 @@ class DefaultItemFactory @Inject constructor(private val avatarSizeProvider: Ava
} else { } else {
"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, readMarkerVisible) val informationData = informationDataFactory.create(event, null)
return create(text, informationData, highlight, callback) return create(text, informationData, highlight, callback)
} }
} }

View File

@ -25,9 +25,10 @@ import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem_
import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.riotx.features.home.room.detail.timeline.helper.MessageItemAttributesFactory import im.vector.riotx.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem_
import im.vector.riotx.features.home.room.detail.timeline.tools.createLinkMovementMethod
import me.gujun.android.span.span import me.gujun.android.span.span
import javax.inject.Inject import javax.inject.Inject
@ -41,7 +42,6 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat
fun create(event: TimelineEvent, fun create(event: TimelineEvent,
nextEvent: TimelineEvent?, nextEvent: TimelineEvent?,
highlight: Boolean, highlight: Boolean,
readMarkerVisible: Boolean,
callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? { callback: TimelineEventController.Callback?): VectorEpoxyModel<*>? {
event.root.eventId ?: return null event.root.eventId ?: return null
@ -57,7 +57,7 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat
} }
val message = stringProvider.getString(R.string.encrypted_message).takeIf { cryptoError == null } val message = stringProvider.getString(R.string.encrypted_message).takeIf { cryptoError == null }
?: stringProvider.getString(R.string.notice_crypto_unable_to_decrypt, errorDescription) ?: stringProvider.getString(R.string.notice_crypto_unable_to_decrypt, errorDescription)
val spannableStr = span(message) { val spannableStr = span(message) {
textStyle = "italic" textStyle = "italic"
textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary) textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary)
@ -65,14 +65,14 @@ 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, readMarkerVisible) val informationData = messageInformationDataFactory.create(event, nextEvent)
val attributes = attributesFactory.create(null, informationData, callback) val attributes = attributesFactory.create(null, informationData, callback)
return MessageTextItem_() return MessageTextItem_()
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.highlighted(highlight) .highlighted(highlight)
.attributes(attributes) .attributes(attributes)
.message(spannableStr) .message(spannableStr)
.urlClickCallback(callback) .movementMethod(createLinkMovementMethod(callback))
} }
else -> null else -> null
} }

View File

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

View File

@ -24,8 +24,6 @@ import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan import android.text.style.ForegroundColorSpan
import android.view.View import android.view.View
import dagger.Lazy import dagger.Lazy
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
import im.vector.matrix.android.api.session.events.model.RelationType import im.vector.matrix.android.api.session.events.model.RelationType
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.message.* import im.vector.matrix.android.api.session.room.model.message.*
@ -35,7 +33,6 @@ 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.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyModel import im.vector.riotx.core.epoxy.VectorEpoxyModel
import im.vector.riotx.core.linkify.VectorLinkify
import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.core.utils.DebouncedClickListener
@ -45,8 +42,10 @@ import im.vector.riotx.core.utils.isLocalFile
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.helper.* import im.vector.riotx.features.home.room.detail.timeline.helper.*
import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.home.room.detail.timeline.item.*
import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.home.room.detail.timeline.tools.createLinkMovementMethod
import im.vector.riotx.features.home.room.detail.timeline.tools.linkify
import im.vector.riotx.features.html.CodeVisitor import im.vector.riotx.features.html.CodeVisitor
import im.vector.riotx.features.html.EventHtmlRenderer
import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.ImageContentRenderer
import im.vector.riotx.features.media.VideoContentRenderer import im.vector.riotx.features.media.VideoContentRenderer
import me.gujun.android.span.span import me.gujun.android.span.span
@ -70,12 +69,11 @@ class MessageItemFactory @Inject constructor(
fun create(event: TimelineEvent, fun create(event: TimelineEvent,
nextEvent: TimelineEvent?, nextEvent: TimelineEvent?,
highlight: Boolean, highlight: Boolean,
readMarkerVisible: 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, readMarkerVisible) val informationData = messageInformationDataFactory.create(event, nextEvent)
if (event.root.isRedacted()) { if (event.root.isRedacted()) {
// message is redacted // message is redacted
@ -89,10 +87,10 @@ class MessageItemFactory @Inject constructor(
return defaultItemFactory.create(malformedText, informationData, highlight, callback) return defaultItemFactory.create(malformedText, informationData, highlight, callback)
} }
if (messageContent.relatesTo?.type == RelationType.REPLACE if (messageContent.relatesTo?.type == RelationType.REPLACE
|| 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, readMarkerVisible, callback) return noticeItemFactory.create(event, highlight, callback)
} }
val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback) val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback)
@ -195,8 +193,7 @@ class MessageItemFactory @Inject constructor(
val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize() val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
val thumbnailData = ImageContentRenderer.Data( val thumbnailData = ImageContentRenderer.Data(
filename = messageContent.body, filename = messageContent.body,
url = messageContent.videoInfo?.thumbnailFile?.url url = messageContent.videoInfo?.thumbnailFile?.url ?: messageContent.videoInfo?.thumbnailUrl,
?: messageContent.videoInfo?.thumbnailUrl,
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = messageContent.videoInfo?.height, height = messageContent.videoInfo?.height,
maxHeight = maxHeight, maxHeight = maxHeight,
@ -258,7 +255,7 @@ class MessageItemFactory @Inject constructor(
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?, callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageTextItem? { attributes: AbsMessageItem.Attributes): MessageTextItem? {
val linkifiedBody = linkifyBody(body, callback) val linkifiedBody = body.linkify(callback)
return MessageTextItem_().apply { return MessageTextItem_().apply {
if (informationData.hasBeenEdited) { if (informationData.hasBeenEdited) {
@ -273,7 +270,7 @@ class MessageItemFactory @Inject constructor(
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.attributes(attributes) .attributes(attributes)
.highlighted(highlight) .highlighted(highlight)
.urlClickCallback(callback) .movementMethod(createLinkMovementMethod(callback))
} }
private fun buildCodeBlockItem(formattedBody: CharSequence, private fun buildCodeBlockItem(formattedBody: CharSequence,
@ -326,9 +323,9 @@ class MessageItemFactory @Inject constructor(
// nop // nop
} }
}, },
editStart, editStart,
editEnd, editEnd,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE) Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
return spannable return spannable
} }
@ -344,14 +341,14 @@ class MessageItemFactory @Inject constructor(
textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary) textColor = colorProvider.getColorFromAttribute(R.attr.riotx_text_secondary)
textStyle = "italic" textStyle = "italic"
} }
linkifyBody(formattedBody, callback) formattedBody.linkify(callback)
} }
return MessageTextItem_() return MessageTextItem_()
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.attributes(attributes) .attributes(attributes)
.message(message) .message(message)
.highlighted(highlight) .highlighted(highlight)
.urlClickCallback(callback) .movementMethod(createLinkMovementMethod(callback))
} }
private fun buildEmoteMessageItem(messageContent: MessageEmoteContent, private fun buildEmoteMessageItem(messageContent: MessageEmoteContent,
@ -361,7 +358,7 @@ class MessageItemFactory @Inject constructor(
attributes: AbsMessageItem.Attributes): MessageTextItem? { attributes: AbsMessageItem.Attributes): MessageTextItem? {
val message = messageContent.body.let { val message = messageContent.body.let {
val formattedBody = "* ${informationData.memberName} $it" val formattedBody = "* ${informationData.memberName} $it"
linkifyBody(formattedBody, callback) formattedBody.linkify(callback)
} }
return MessageTextItem_() return MessageTextItem_()
.apply { .apply {
@ -375,7 +372,7 @@ class MessageItemFactory @Inject constructor(
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.attributes(attributes) .attributes(attributes)
.highlighted(highlight) .highlighted(highlight)
.urlClickCallback(callback) .movementMethod(createLinkMovementMethod(callback))
} }
private fun buildRedactedItem(attributes: AbsMessageItem.Attributes, private fun buildRedactedItem(attributes: AbsMessageItem.Attributes,
@ -386,17 +383,6 @@ class MessageItemFactory @Inject constructor(
.highlighted(highlight) .highlighted(highlight)
} }
private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): CharSequence {
val spannable = SpannableStringBuilder(body)
MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback {
override fun onUrlClicked(url: String) {
callback?.onUrlClicked(url)
}
})
VectorLinkify.addLinks(spannable, true)
return spannable
}
companion object { companion object {
private const val MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT = 5 private const val MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT = 5
} }

View File

@ -34,10 +34,9 @@ class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEv
fun create(event: TimelineEvent, fun create(event: TimelineEvent,
highlight: Boolean, highlight: Boolean,
readMarkerVisible: 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, readMarkerVisible) val informationData = informationDataFactory.create(event, null)
val attributes = NoticeItem.Attributes( val attributes = NoticeItem.Attributes(
avatarRenderer = avatarRenderer, avatarRenderer = avatarRenderer,
informationData = informationData, informationData = informationData,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -25,7 +25,6 @@ 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 import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
@ -39,13 +38,6 @@ abstract class MergedHeaderItem : BaseEventItem<MergedHeaderItem.Holder>() {
attributes.mergeData.distinctBy { it.userId } attributes.mergeData.distinctBy { it.userId }
} }
private val _readMarkerCallback = object : ReadMarkerView.Callback {
override fun onReadMarkerLongBound(isDisplayed: Boolean) {
attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.readMarkerId ?: "", isDisplayed)
}
}
override fun getViewType() = STUB_ID override fun getViewType() = STUB_ID
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
@ -77,20 +69,14 @@ 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)
} }
override fun getEventIds(): List<String> { override fun getEventIds(): List<String> {
return attributes.mergeData.map { it.eventId } return if (attributes.isCollapsed) {
attributes.mergeData.map { it.eventId }
} else {
emptyList()
}
} }
data class Data( data class Data(
@ -102,9 +88,7 @@ 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 mergeData: List<Data>, val mergeData: List<Data>,
val avatarRenderer: AvatarRenderer, val avatarRenderer: AvatarRenderer,
val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null,
@ -119,6 +103,6 @@ abstract class MergedHeaderItem : BaseEventItem<MergedHeaderItem.Holder>() {
} }
companion object { companion object {
private const val STUB_ID = R.id.messageContentMergedheaderStub private const val STUB_ID = R.id.messageContentMergedHeaderStub
} }
} }

View File

@ -33,9 +33,7 @@ data class MessageInformationData(
val orderedReactionList: List<ReactionInfoData>? = null, val orderedReactionList: List<ReactionInfoData>? = null,
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
) : Parcelable ) : Parcelable
@Parcelize @Parcelize

View File

@ -16,22 +16,14 @@
package im.vector.riotx.features.home.room.detail.timeline.item package im.vector.riotx.features.home.room.detail.timeline.item
import android.view.MotionEvent import android.text.method.MovementMethod
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.PrecomputedTextCompat import androidx.core.text.PrecomputedTextCompat
import androidx.core.text.toSpannable
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
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.utils.isValidUrl import im.vector.riotx.features.home.room.detail.timeline.tools.findPillsAndProcess
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.html.PillImageSpan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.saket.bettermovementmethod.BetterLinkMovementMethod
@EpoxyModelClass(layout = R.layout.item_timeline_event_base) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() { abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@ -43,30 +35,11 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@EpoxyAttribute @EpoxyAttribute
var useBigFont: Boolean = false var useBigFont: Boolean = false
@EpoxyAttribute @EpoxyAttribute
var urlClickCallback: TimelineEventController.UrlClickCallback? = null var movementMethod: MovementMethod? = null
// Better link movement methods fixes the issue when
// long pressing to open the context menu on a TextView also triggers an autoLink click.
private val mvmtMethod = BetterLinkMovementMethod.newInstance().also {
it.setOnLinkClickListener { _, url ->
// Return false to let android manage the click on the link, or true if the link is handled by the application
url.isValidUrl() && urlClickCallback?.onUrlClicked(url) == true
}
// We need also to fix the case when long click on link will trigger long click on cell
it.setOnLinkLongClickListener { tv, url ->
// Long clicks are handled by parent, return true to block android to do something with url
if (url.isValidUrl() && urlClickCallback?.onUrlLongClicked(url) == true) {
tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0))
true
} else {
false
}
}
}
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
holder.messageView.movementMethod = mvmtMethod holder.messageView.movementMethod = movementMethod
if (useBigFont) { if (useBigFont) {
holder.messageView.textSize = 44F holder.messageView.textSize = 44F
} else { } else {
@ -76,7 +49,7 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
holder.messageView.setOnClickListener(attributes.itemClickListener) holder.messageView.setOnClickListener(attributes.itemClickListener)
holder.messageView.setOnLongClickListener(attributes.itemLongClickListener) holder.messageView.setOnLongClickListener(attributes.itemLongClickListener)
if (searchForPills) { if (searchForPills) {
findPillsAndProcess { it.bind(holder.messageView) } message?.findPillsAndProcess { it.bind(holder.messageView) }
} }
val textFuture = PrecomputedTextCompat.getTextFuture( val textFuture = PrecomputedTextCompat.getTextFuture(
message ?: "", message ?: "",
@ -85,17 +58,6 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
holder.messageView.setTextFuture(textFuture) holder.messageView.setTextFuture(textFuture)
} }
private fun findPillsAndProcess(processBlock: (span: PillImageSpan) -> Unit) {
GlobalScope.launch(Dispatchers.Main) {
val pillImageSpans: Array<PillImageSpan>? = withContext(Dispatchers.IO) {
message?.toSpannable()?.let { spannable ->
spannable.getSpans(0, spannable.length, PillImageSpan::class.java)
}
}
pillImageSpans?.forEach { processBlock(it) }
}
}
override fun getViewType() = STUB_ID override fun getViewType() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) { class Holder : AbsMessageItem.Holder(STUB_ID) {

View File

@ -22,7 +22,6 @@ import android.widget.TextView
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.core.utils.DebouncedClickListener import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
@ -37,13 +36,6 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
attributes.readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts) attributes.readReceiptsCallback?.onReadReceiptsClicked(attributes.informationData.readReceipts)
}) })
private val _readMarkerCallback = object : ReadMarkerView.Callback {
override fun onReadMarkerLongBound(isDisplayed: Boolean) {
attributes.readReceiptsCallback?.onReadMarkerLongBound(attributes.informationData.eventId, isDisplayed)
}
}
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
holder.noticeTextView.text = attributes.noticeText holder.noticeTextView.text = attributes.noticeText
@ -56,17 +48,6 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
) )
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.eventId,
attributes.informationData.hasReadMarker,
attributes.informationData.displayReadMarker,
_readMarkerCallback
)
}
override fun unbind(holder: Holder) {
holder.readMarkerView.unbind()
super.unbind(holder)
} }
override fun getEventIds(): List<String> { override fun getEventIds(): List<String> {

View File

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

View File

@ -0,0 +1,76 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotx.features.home.room.detail.timeline.tools
import android.text.SpannableStringBuilder
import android.view.MotionEvent
import androidx.core.text.toSpannable
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
import im.vector.riotx.core.linkify.VectorLinkify
import im.vector.riotx.core.utils.isValidUrl
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.html.PillImageSpan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.saket.bettermovementmethod.BetterLinkMovementMethod
fun CharSequence.findPillsAndProcess(processBlock: (PillImageSpan) -> Unit) {
GlobalScope.launch(Dispatchers.Main) {
withContext(Dispatchers.IO) {
toSpannable().let { spannable ->
spannable.getSpans(0, spannable.length, PillImageSpan::class.java)
}
}.forEach { processBlock(it) }
}
}
fun CharSequence.linkify(callback: TimelineEventController.UrlClickCallback?): CharSequence {
val spannable = SpannableStringBuilder(this)
MatrixLinkify.addLinks(spannable, object : MatrixPermalinkSpan.Callback {
override fun onUrlClicked(url: String) {
callback?.onUrlClicked(url)
}
})
VectorLinkify.addLinks(spannable, true)
return spannable
}
// Better link movement methods fixes the issue when
// long pressing to open the context menu on a TextView also triggers an autoLink click.
fun createLinkMovementMethod(urlClickCallback: TimelineEventController.UrlClickCallback?): BetterLinkMovementMethod {
return BetterLinkMovementMethod.newInstance()
.apply {
setOnLinkClickListener { _, url ->
// Return false to let android manage the click on the link, or true if the link is handled by the application
url.isValidUrl() && urlClickCallback?.onUrlClicked(url) == true
}
// We need also to fix the case when long click on link will trigger long click on cell
setOnLinkLongClickListener { tv, url ->
// Long clicks are handled by parent, return true to block android to do something with url
if (url.isValidUrl() && urlClickCallback?.onUrlLongClicked(url) == true) {
tv.dispatchTouchEvent(MotionEvent.obtain(0, 0, MotionEvent.ACTION_CANCEL, 0f, 0f, 0))
true
} else {
false
}
}
}
}

View File

@ -18,9 +18,9 @@ package im.vector.riotx.features.home.room.list.actions
import android.view.View import android.view.View
import com.airbnb.epoxy.TypedEpoxyController import com.airbnb.epoxy.TypedEpoxyController
import im.vector.matrix.android.api.session.room.notification.RoomNotificationState import im.vector.matrix.android.api.session.room.notification.RoomNotificationState
import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetItemAction import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetActionItem
import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetItemRoomPreview import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetRoomPreviewItem
import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetItemSeparator import im.vector.riotx.core.epoxy.bottomsheet.bottomSheetSeparatorItem
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
import javax.inject.Inject import javax.inject.Inject
@ -36,7 +36,7 @@ class RoomListQuickActionsEpoxyController @Inject constructor(private val avatar
val roomSummary = state.roomSummary() ?: return val roomSummary = state.roomSummary() ?: return
// Preview // Preview
bottomSheetItemRoomPreview { bottomSheetRoomPreviewItem {
id("preview") id("preview")
avatarRenderer(avatarRenderer) avatarRenderer(avatarRenderer)
roomName(roomSummary.displayName) roomName(roomSummary.displayName)
@ -46,7 +46,7 @@ class RoomListQuickActionsEpoxyController @Inject constructor(private val avatar
} }
// Notifications // Notifications
bottomSheetItemSeparator { bottomSheetSeparatorItem {
id("notifications_separator") id("notifications_separator")
} }
@ -57,7 +57,7 @@ class RoomListQuickActionsEpoxyController @Inject constructor(private val avatar
RoomListQuickActionsSharedAction.NotificationsMute(roomSummary.roomId).toBottomSheetItem(3, selectedRoomState) RoomListQuickActionsSharedAction.NotificationsMute(roomSummary.roomId).toBottomSheetItem(3, selectedRoomState)
// Leave // Leave
bottomSheetItemSeparator { bottomSheetSeparatorItem {
id("leave_separator") id("leave_separator")
} }
RoomListQuickActionsSharedAction.Leave(roomSummary.roomId).toBottomSheetItem(5) RoomListQuickActionsSharedAction.Leave(roomSummary.roomId).toBottomSheetItem(5)
@ -72,7 +72,7 @@ class RoomListQuickActionsEpoxyController @Inject constructor(private val avatar
is RoomListQuickActionsSharedAction.Settings, is RoomListQuickActionsSharedAction.Settings,
is RoomListQuickActionsSharedAction.Leave -> false is RoomListQuickActionsSharedAction.Leave -> false
} }
return bottomSheetItemAction { return bottomSheetActionItem {
id("action_$index") id("action_$index")
selected(selected) selected(selected)
iconRes(iconResId) iconRes(iconResId)

View File

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

View File

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

View File

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

View File

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

View File

@ -1783,7 +1783,7 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming
<string name="report_content_spam">"It's spam"</string> <string name="report_content_spam">"It's spam"</string>
<string name="report_content_inappropriate">"It's inappropriate"</string> <string name="report_content_inappropriate">"It's inappropriate"</string>
<string name="report_content_custom">"Custom report"</string> <string name="report_content_custom">"Custom report"</string>
<string name="report_content_custom_title">"Report this content"</string> <string name="report_content_custom_title">"Report this content"</string>
<string name="report_content_custom_hint">"Reason for reporting this content"</string> <string name="report_content_custom_hint">"Reason for reporting this content"</string>
<string name="report_content_custom_submit">"REPORT"</string> <string name="report_content_custom_submit">"REPORT"</string>

View File

@ -22,6 +22,7 @@
<string name="room_join_rules_public">%1$s made the room public to whoever knows the link.</string> <string name="room_join_rules_public">%1$s made the room public to whoever knows the link.</string>
<string name="room_join_rules_invite">%1$s made the room invite only.</string> <string name="room_join_rules_invite">%1$s made the room invite only.</string>
<string name="timeline_unread_messages">Unread messages</string>
<string name="login_splash_title">Liberate your communication</string> <string name="login_splash_title">Liberate your communication</string>
<string name="login_splash_text1">Chat with people directly or in groups</string> <string name="login_splash_text1">Chat with people directly or in groups</string>
@ -137,4 +138,6 @@
<item quantity="other">Too many requests have been sent. You can retry in %1$d seconds…</item> <item quantity="other">Too many requests have been sent. You can retry in %1$d seconds…</item>
</plurals> </plurals>
<string name="seen_by">Seen by</string>
</resources> </resources>

View File

@ -266,6 +266,7 @@
<item name="android:textColorSecondary">@color/riot_secondary_text_color_dark</item> <item name="android:textColorSecondary">@color/riot_secondary_text_color_dark</item>
<!-- Default color for text View --> <!-- Default color for text View -->
<item name="android:textColorTertiary">@color/riot_tertiary_text_color_dark</item> <item name="android:textColorTertiary">@color/riot_tertiary_text_color_dark</item>
<item name="android:textColorLink">@color/riotx_links</item>
</style> </style>
<style name="Vector.BottomSheet.Light" parent="Theme.Design.Light.BottomSheetDialog"> <style name="Vector.BottomSheet.Light" parent="Theme.Design.Light.BottomSheetDialog">
@ -273,6 +274,7 @@
<item name="android:textColorSecondary">@color/riot_secondary_text_color_light</item> <item name="android:textColorSecondary">@color/riot_secondary_text_color_light</item>
<!-- Default color for text View --> <!-- Default color for text View -->
<item name="android:textColorTertiary">@color/riot_tertiary_text_color_light</item> <item name="android:textColorTertiary">@color/riot_tertiary_text_color_light</item>
<item name="android:textColorLink">@color/riotx_links</item>
</style> </style>
<style name="Vector.BottomSheet.Status" parent="Theme.Design.Light.BottomSheetDialog"> <style name="Vector.BottomSheet.Status" parent="Theme.Design.Light.BottomSheetDialog">
@ -280,6 +282,7 @@
<item name="android:textColorSecondary">@color/riot_secondary_text_color_status</item> <item name="android:textColorSecondary">@color/riot_secondary_text_color_status</item>
<!-- Default color for text View --> <!-- Default color for text View -->
<item name="android:textColorTertiary">@color/riot_tertiary_text_color_status</item> <item name="android:textColorTertiary">@color/riot_tertiary_text_color_status</item>
<item name="android:textColorLink">@color/link_color_status</item>
</style> </style>