Merge pull request #484 from vector-im/feature/timeline_read_receipts

Feature/timeline read receipts
This commit is contained in:
ganfra 2019-08-19 14:29:59 +02:00 committed by GitHub
commit bc1350aaf5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 1366 additions and 351 deletions

View File

@ -2,7 +2,7 @@ Changes in RiotX 0.4.0 (2019-XX-XX)
=================================================== ===================================================


Features: Features:
- - Display read receipts in timeline (#81)


Improvements: Improvements:
- -

View File

@ -18,6 +18,7 @@ package im.vector.matrix.rx


import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.model.ReadReceipt
import im.vector.matrix.android.api.session.room.model.RoomSummary import im.vector.matrix.android.api.session.room.model.RoomSummary
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import io.reactivex.Observable import io.reactivex.Observable
@ -49,6 +50,10 @@ class RxRoom(private val room: Room) {
room.join(viaServers, MatrixCallbackSingle(it)).toSingle(it) room.join(viaServers, MatrixCallbackSingle(it)).toSingle(it)
} }


fun liveEventReadReceipts(eventId: String): Observable<List<ReadReceipt>> {
return room.getEventReadReceiptsLive(eventId).asObservable()
}

} }


fun Room.rx(): RxRoom { fun Room.rx(): RxRoom {

View File

@ -16,8 +16,9 @@


package im.vector.matrix.android.api.session.room.model package im.vector.matrix.android.api.session.room.model


import im.vector.matrix.android.api.session.user.model.User

data class ReadReceipt( data class ReadReceipt(
val userId: String, val user: User,
val eventId: String,
val originServerTs: Long val originServerTs: Long
) )

View File

@ -16,7 +16,9 @@


package im.vector.matrix.android.api.session.room.read package im.vector.matrix.android.api.session.room.read


import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.room.model.ReadReceipt


/** /**
* This interface defines methods to handle read receipts and read marker in a room. It's implemented at the room level. * This interface defines methods to handle read receipts and read marker in a room. It's implemented at the room level.
@ -39,4 +41,6 @@ interface ReadService {
fun setReadMarker(fullyReadEventId: String, callback: MatrixCallback<Unit>) fun setReadMarker(fullyReadEventId: String, callback: MatrixCallback<Unit>)


fun isEventRead(eventId: String): Boolean fun isEventRead(eventId: String): Boolean

fun getEventReadReceiptsLive(eventId: String): LiveData<List<ReadReceipt>>
} }

View File

@ -20,6 +20,7 @@ import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.model.ReadReceipt
import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.isReply import im.vector.matrix.android.api.session.room.model.message.isReply
import im.vector.matrix.android.api.util.ContentUtils.extractUsefulTextFromReply import im.vector.matrix.android.api.util.ContentUtils.extractUsefulTextFromReply
@ -37,7 +38,8 @@ data class TimelineEvent(
val senderName: String?, val senderName: String?,
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 metadata = HashMap<String, Any>() val metadata = HashMap<String, Any>()

View File

@ -25,12 +25,12 @@ interface TimelineService {


/** /**
* Instantiate a [Timeline] with an optional initial eventId, to be used with permalink. * Instantiate a [Timeline] with an optional initial eventId, to be used with permalink.
* You can filter the type you want to grab with the allowedTypes param. * You can also configure some settings with the [settings] param.
* @param eventId the optional initial eventId. * @param eventId the optional initial eventId.
* @param allowedTypes the optional filter types * @param settings settings to configure the timeline.
* @return the instantiated timeline * @return the instantiated timeline
*/ */
fun createTimeline(eventId: String?, allowedTypes: List<String>? = null): Timeline fun createTimeline(eventId: String?, settings: TimelineSettings): Timeline




fun getTimeLineEvent(eventId: String): TimelineEvent? fun getTimeLineEvent(eventId: String): TimelineEvent?

View File

@ -0,0 +1,44 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package im.vector.matrix.android.api.session.room.timeline

/**
* Data class holding setting values for a [Timeline] instance.
*/
data class TimelineSettings(
/**
* The initial number of events to retrieve from cache. You might get less events if you don't have loaded enough yet.
*/
val initialSize: Int,
/**
* A flag to filter edit events
*/
val filterEdits: Boolean = false,
/**
* A flag to filter by types. It should be used with [allowedTypes] field
*/
val filterTypes: Boolean = false,
/**
* If [filterTypes] is true, the list of types allowed by the list.
*/
val allowedTypes: List<String> = emptyList(),
/**
* If true, will build read receipts for each event.
*/
val buildReadReceipts: Boolean = true

)

View File

@ -23,9 +23,12 @@ 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.ReadReceiptEntity
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
import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields import im.vector.matrix.android.internal.database.model.TimelineEventEntityFields
import im.vector.matrix.android.internal.database.query.find import im.vector.matrix.android.internal.database.query.find
import im.vector.matrix.android.internal.database.query.getOrCreate
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.extensions.assertIsManaged import im.vector.matrix.android.internal.extensions.assertIsManaged
import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection
@ -133,6 +136,28 @@ internal fun ChunkEntity.add(roomId: String,
} }


val localId = TimelineEventEntity.nextId(realm) val localId = TimelineEventEntity.nextId(realm)
val eventId = event.eventId ?: ""
val senderId = event.senderId ?: ""

val readReceiptsSummaryEntity = ReadReceiptsSummaryEntity.where(realm, eventId).findFirst()
?: ReadReceiptsSummaryEntity(eventId, roomId)

// Update RR for the sender of a new message with a dummy one

if (event.originServerTs != null) {
val timestampOfEvent = event.originServerTs.toDouble()
val readReceiptOfSender = ReadReceiptEntity.getOrCreate(realm, roomId = roomId, userId = senderId)
// If the synced RR is older, update
if (timestampOfEvent > readReceiptOfSender.originServerTs) {
val previousReceiptsSummary = ReadReceiptsSummaryEntity.where(realm, eventId = readReceiptOfSender.eventId).findFirst()
readReceiptOfSender.eventId = eventId
readReceiptOfSender.originServerTs = timestampOfEvent
previousReceiptsSummary?.readReceipts?.remove(readReceiptOfSender)
readReceiptsSummaryEntity.readReceipts.add(readReceiptOfSender)
}
}


val eventEntity = TimelineEventEntity(localId).also { val eventEntity = TimelineEventEntity(localId).also {
it.root = event.toEntity(roomId).apply { it.root = event.toEntity(roomId).apply {
this.stateIndex = currentStateIndex this.stateIndex = currentStateIndex
@ -140,9 +165,10 @@ internal fun ChunkEntity.add(roomId: String,
this.displayIndex = currentDisplayIndex this.displayIndex = currentDisplayIndex
this.sendState = SendState.SYNCED this.sendState = SendState.SYNCED
} }
it.eventId = event.eventId ?: "" it.eventId = eventId
it.roomId = roomId it.roomId = roomId
it.annotations = EventAnnotationsSummaryEntity.where(realm, it.eventId).findFirst() it.annotations = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
it.readReceipts = readReceiptsSummaryEntity
} }
val position = if (direction == PaginationDirection.FORWARDS) 0 else this.timelineEvents.size val position = if (direction == PaginationDirection.FORWARDS) 0 else this.timelineEvents.size
timelineEvents.add(position, eventEntity) timelineEvents.add(position, eventEntity)

View File

@ -0,0 +1,45 @@
/*
* 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.database.mapper

import im.vector.matrix.android.api.session.room.model.ReadReceipt
import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity
import im.vector.matrix.android.internal.database.model.UserEntity
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.di.SessionDatabase
import io.realm.Realm
import io.realm.RealmConfiguration
import javax.inject.Inject

internal class ReadReceiptsSummaryMapper @Inject constructor(@SessionDatabase private val realmConfiguration: RealmConfiguration) {

fun map(readReceiptsSummaryEntity: ReadReceiptsSummaryEntity?): List<ReadReceipt> {
if (readReceiptsSummaryEntity == null) {
return emptyList()
}
return Realm.getInstance(realmConfiguration).use { realm ->
val readReceipts = readReceiptsSummaryEntity.readReceipts
readReceipts
.mapNotNull {
val user = UserEntity.where(realm, it.userId).findFirst()
?: return@mapNotNull null
ReadReceipt(user.asDomain(), it.originServerTs.toLong())
}
}
}

}

View File

@ -26,7 +26,8 @@ import java.util.*
import javax.inject.Inject import javax.inject.Inject


internal class RoomSummaryMapper @Inject constructor( internal class RoomSummaryMapper @Inject constructor(
val cryptoService: CryptoService val cryptoService: CryptoService,
val timelineEventMapper: TimelineEventMapper
) { ) {


fun map(roomSummaryEntity: RoomSummaryEntity): RoomSummary { fun map(roomSummaryEntity: RoomSummaryEntity): RoomSummary {
@ -34,7 +35,9 @@ internal class RoomSummaryMapper @Inject constructor(
RoomTag(it.tagName, it.tagOrder) RoomTag(it.tagName, it.tagOrder)
} }


val latestEvent = roomSummaryEntity.latestEvent?.asDomain() val latestEvent = roomSummaryEntity.latestEvent?.let {
timelineEventMapper.map(it)
}
if (latestEvent?.root?.isEncrypted() == true && latestEvent.root.mxDecryptionResult == null) { if (latestEvent?.root?.isEncrypted() == true && latestEvent.root.mxDecryptionResult == null) {
//TODO use a global event decryptor? attache to session and that listen to new sessionId? //TODO use a global event decryptor? attache to session and that listen to new sessionId?
//for now decrypt sync //for now decrypt sync

View File

@ -17,13 +17,23 @@
package im.vector.matrix.android.internal.database.mapper package im.vector.matrix.android.internal.database.mapper


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.room.model.ReadReceipt

import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.database.model.TimelineEventEntity import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import javax.inject.Inject


internal object TimelineEventMapper { internal class TimelineEventMapper @Inject constructor(private val readReceiptsSummaryMapper: ReadReceiptsSummaryMapper) {

fun map(timelineEventEntity: TimelineEventEntity): TimelineEvent {


fun map(timelineEventEntity: TimelineEventEntity, buildReadReceipts: Boolean = true, correctedReadReceipts: List<ReadReceipt>? = null): TimelineEvent {
val readReceipts = if (buildReadReceipts) {
correctedReadReceipts ?: timelineEventEntity.readReceipts
?.let {
readReceiptsSummaryMapper.map(it)
}
} else {
null
}
return TimelineEvent( return TimelineEvent(
root = timelineEventEntity.root?.asDomain() root = timelineEventEntity.root?.asDomain()
?: Event("", timelineEventEntity.eventId), ?: Event("", timelineEventEntity.eventId),
@ -32,14 +42,13 @@ internal object TimelineEventMapper {
displayIndex = timelineEventEntity.root?.displayIndex ?: 0, displayIndex = timelineEventEntity.root?.displayIndex ?: 0,
senderName = timelineEventEntity.senderName, senderName = timelineEventEntity.senderName,
isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName, isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName,
senderAvatar = timelineEventEntity.senderAvatar senderAvatar = timelineEventEntity.senderAvatar,
readReceipts = readReceipts?.sortedByDescending {
it.originServerTs
} ?: emptyList()
) )
} }


} }


internal fun TimelineEventEntity.asDomain(): TimelineEvent {
return TimelineEventMapper.map(this)
}




View File

@ -17,13 +17,18 @@
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 ReadReceiptEntity(@PrimaryKey var primaryKey: String = "", internal open class ReadReceiptEntity(@PrimaryKey var primaryKey: String = "",
var userId: String = "",
var eventId: String = "", var eventId: String = "",
var roomId: String = "", var roomId: String = "",
var userId: String = "",
var originServerTs: Double = 0.0 var originServerTs: Double = 0.0
) : RealmObject() { ) : RealmObject() {
companion object companion object

@LinkingObjects("readReceipts")
val summary: RealmResults<ReadReceiptsSummaryEntity>? = null
} }

View File

@ -0,0 +1,37 @@
/*
* 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.database.model

import io.realm.RealmList
import io.realm.RealmObject
import io.realm.RealmResults
import io.realm.annotations.LinkingObjects
import io.realm.annotations.PrimaryKey

internal open class ReadReceiptsSummaryEntity(
@PrimaryKey
var eventId: String = "",
var roomId: String = "",
var readReceipts: RealmList<ReadReceiptEntity> = RealmList()
) : RealmObject() {

@LinkingObjects("readReceipts")
val timelineEvent: RealmResults<TimelineEventEntity>? = null

companion object

}

View File

@ -42,6 +42,7 @@ import io.realm.annotations.RealmModule
PushRuleEntity::class, PushRuleEntity::class,
PushConditionEntity::class, PushConditionEntity::class,
PusherEntity::class, PusherEntity::class,
PusherDataEntity::class PusherDataEntity::class,
ReadReceiptsSummaryEntity::class
]) ])
internal class SessionRealmModule internal class SessionRealmModule

View File

@ -30,7 +30,8 @@ internal open class TimelineEventEntity(var localId: Long = 0,
var senderName: String? = null, var senderName: String? = null,
var isUniqueDisplayName: Boolean = false, var isUniqueDisplayName: Boolean = false,
var senderAvatar: String? = null, var senderAvatar: String? = null,
var senderMembershipEvent: EventEntity? = null var senderMembershipEvent: EventEntity? = null,
var readReceipts: ReadReceiptsSummaryEntity? = null
) : RealmObject() { ) : RealmObject() {


@LinkingObjects("timelineEvents") @LinkingObjects("timelineEvents")

View File

@ -0,0 +1,23 @@
/*
* 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.database.query

internal object FilterContent {

internal const val EDIT_TYPE = """{*"m.relates_to"*"rel_type":*"m.replace"*}"""

}

View File

@ -27,3 +27,21 @@ internal fun ReadReceiptEntity.Companion.where(realm: Realm, roomId: String, use
.equalTo(ReadReceiptEntityFields.ROOM_ID, roomId) .equalTo(ReadReceiptEntityFields.ROOM_ID, roomId)
.equalTo(ReadReceiptEntityFields.USER_ID, userId) .equalTo(ReadReceiptEntityFields.USER_ID, userId)
} }

internal fun ReadReceiptEntity.Companion.createUnmanaged(roomId: String, eventId: String, userId: String, originServerTs: Double): ReadReceiptEntity {
return ReadReceiptEntity().apply {
this.primaryKey = "${roomId}_$userId"
this.eventId = eventId
this.roomId = roomId
this.userId = userId
this.originServerTs = originServerTs
}
}

internal fun ReadReceiptEntity.Companion.getOrCreate(realm: Realm, roomId: String, userId: String): ReadReceiptEntity {
return ReadReceiptEntity.where(realm, roomId, userId).findFirst()
?: realm.createObject(ReadReceiptEntity::class.java, "${roomId}_$userId").apply {
this.roomId = roomId
this.userId = userId
}
}

View File

@ -0,0 +1,36 @@
/*
* 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.database.query

import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntityFields
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.kotlin.where

internal fun ReadReceiptsSummaryEntity.Companion.where(realm: Realm, eventId: String): RealmQuery<ReadReceiptsSummaryEntity> {
return realm.where<ReadReceiptsSummaryEntity>()
.equalTo(ReadReceiptsSummaryEntityFields.EVENT_ID, eventId)
}

internal fun ReadReceiptsSummaryEntity.Companion.whereInRoom(realm: Realm, roomId: String?): RealmQuery<ReadReceiptsSummaryEntity> {
val query = realm.where<ReadReceiptsSummaryEntity>()
if (roomId != null) {
query.equalTo(ReadReceiptsSummaryEntityFields.ROOM_ID, roomId)
}
return query
}

View File

@ -21,7 +21,9 @@ import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.crypto.CryptoService import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.internal.database.mapper.ReadReceiptsSummaryMapper
import im.vector.matrix.android.internal.database.mapper.RoomSummaryMapper import im.vector.matrix.android.internal.database.mapper.RoomSummaryMapper
import im.vector.matrix.android.internal.database.mapper.TimelineEventMapper
import im.vector.matrix.android.internal.session.room.membership.DefaultMembershipService import im.vector.matrix.android.internal.session.room.membership.DefaultMembershipService
import im.vector.matrix.android.internal.session.room.membership.LoadRoomMembersTask import im.vector.matrix.android.internal.session.room.membership.LoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.membership.joining.InviteTask import im.vector.matrix.android.internal.session.room.membership.joining.InviteTask
@ -47,6 +49,8 @@ internal class RoomFactory @Inject constructor(private val context: Context,
private val monarchy: Monarchy, private val monarchy: Monarchy,
private val eventFactory: LocalEchoEventFactory, private val eventFactory: LocalEchoEventFactory,
private val roomSummaryMapper: RoomSummaryMapper, private val roomSummaryMapper: RoomSummaryMapper,
private val timelineEventMapper: TimelineEventMapper,
private val readReceiptsSummaryMapper: ReadReceiptsSummaryMapper,
private val taskExecutor: TaskExecutor, private val taskExecutor: TaskExecutor,
private val loadRoomMembersTask: LoadRoomMembersTask, private val loadRoomMembersTask: LoadRoomMembersTask,
private val inviteTask: InviteTask, private val inviteTask: InviteTask,
@ -61,13 +65,29 @@ internal class RoomFactory @Inject constructor(private val context: Context,
private val leaveRoomTask: LeaveRoomTask) { private val leaveRoomTask: LeaveRoomTask) {


fun create(roomId: String): Room { fun create(roomId: String): Room {
val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, cryptoService, paginationTask) val timelineService = DefaultTimelineService(roomId,
monarchy,
taskExecutor,
contextOfEventTask,
cryptoService,
paginationTask,
timelineEventMapper,
readReceiptsSummaryMapper
)
val sendService = DefaultSendService(context, credentials, roomId, eventFactory, cryptoService, monarchy) val sendService = DefaultSendService(context, credentials, roomId, eventFactory, cryptoService, monarchy)
val stateService = DefaultStateService(roomId, monarchy.realmConfiguration, taskExecutor, sendStateTask) val stateService = DefaultStateService(roomId, monarchy.realmConfiguration, taskExecutor, sendStateTask)
val roomMembersService = DefaultMembershipService(roomId, monarchy, taskExecutor, loadRoomMembersTask, inviteTask, joinRoomTask, leaveRoomTask) val roomMembersService = DefaultMembershipService(roomId, monarchy, taskExecutor, loadRoomMembersTask, inviteTask, joinRoomTask, leaveRoomTask)
val readService = DefaultReadService(roomId, monarchy, taskExecutor, setReadMarkersTask, credentials) val readService = DefaultReadService(roomId, monarchy, taskExecutor, setReadMarkersTask, readReceiptsSummaryMapper, credentials)
val relationService = DefaultRelationService(context, val relationService = DefaultRelationService(context,
credentials, roomId, eventFactory, cryptoService, findReactionEventForUndoTask, fetchEditHistoryTask, monarchy, taskExecutor) credentials,
roomId,
eventFactory,
cryptoService,
findReactionEventForUndoTask,
fetchEditHistoryTask,
monarchy,
taskExecutor
)


return DefaultRoom( return DefaultRoom(
roomId, roomId,

View File

@ -16,12 +16,21 @@


package im.vector.matrix.android.internal.session.room.read package im.vector.matrix.android.internal.session.room.read


import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.model.ReadReceipt
import im.vector.matrix.android.api.session.room.read.ReadService import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.internal.database.RealmLiveData
import im.vector.matrix.android.internal.database.mapper.ReadReceiptsSummaryMapper
import im.vector.matrix.android.internal.database.mapper.asDomain
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.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.query.find import im.vector.matrix.android.internal.database.query.find
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
@ -33,6 +42,7 @@ internal class DefaultReadService @Inject constructor(private val roomId: String
private val monarchy: Monarchy, private val monarchy: Monarchy,
private val taskExecutor: TaskExecutor, private val taskExecutor: TaskExecutor,
private val setReadMarkersTask: SetReadMarkersTask, private val setReadMarkersTask: SetReadMarkersTask,
private val readReceiptsSummaryMapper: ReadReceiptsSummaryMapper,
private val credentials: Credentials) : ReadService { private val credentials: Credentials) : ReadService {


override fun markAllAsRead(callback: MatrixCallback<Unit>) { override fun markAllAsRead(callback: MatrixCallback<Unit>) {
@ -79,4 +89,16 @@ internal class DefaultReadService @Inject constructor(private val roomId: String
return isEventRead return isEventRead
} }


override fun getEventReadReceiptsLive(eventId: String): LiveData<List<ReadReceipt>> {
val liveEntity = RealmLiveData(monarchy.realmConfiguration) { realm ->
ReadReceiptsSummaryEntity.where(realm, eventId)
}
return Transformations.map(liveEntity) { realmResults ->
realmResults.firstOrNull()?.let {
readReceiptsSummaryMapper.map(it)
}?.sortedByDescending {
it.originServerTs
} ?: emptyList()
}
}
} }

View File

@ -19,21 +19,41 @@ package im.vector.matrix.android.internal.session.room.timeline
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.crypto.CryptoService import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.model.ReadReceipt
import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.send.SendState
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.matrix.android.api.session.room.timeline.TimelineSettings
import im.vector.matrix.android.api.util.CancelableBag import im.vector.matrix.android.api.util.CancelableBag
import im.vector.matrix.android.internal.database.mapper.TimelineEventMapper
import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.* import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.ChunkEntityFields
import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity
import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.query.* import im.vector.matrix.android.internal.database.model.EventEntityFields
import im.vector.matrix.android.internal.database.model.RoomEntity
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.findAllInRoomWithSendStates
import im.vector.matrix.android.internal.database.query.findIncludingEvent
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.database.query.whereInRoom
import im.vector.matrix.android.internal.task.TaskConstraints import im.vector.matrix.android.internal.task.TaskConstraints
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.Debouncer import im.vector.matrix.android.internal.util.Debouncer
import im.vector.matrix.android.internal.util.createBackgroundHandler import im.vector.matrix.android.internal.util.createBackgroundHandler
import im.vector.matrix.android.internal.util.createUIHandler import im.vector.matrix.android.internal.util.createUIHandler
import io.realm.* import io.realm.OrderedCollectionChangeSet
import io.realm.OrderedRealmCollectionChangeListener
import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.RealmQuery
import io.realm.RealmResults
import io.realm.Sort
import timber.log.Timber import timber.log.Timber
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -42,7 +62,6 @@ import kotlin.collections.ArrayList
import kotlin.collections.HashMap import kotlin.collections.HashMap




private const val INITIAL_LOAD_SIZE = 30
private const val MIN_FETCHING_COUNT = 30 private const val MIN_FETCHING_COUNT = 30
private const val DISPLAY_INDEX_UNKNOWN = Int.MIN_VALUE private const val DISPLAY_INDEX_UNKNOWN = Int.MIN_VALUE


@ -53,9 +72,11 @@ internal class DefaultTimeline(
private val taskExecutor: TaskExecutor, private val taskExecutor: TaskExecutor,
private val contextOfEventTask: GetContextOfEventTask, private val contextOfEventTask: GetContextOfEventTask,
private val paginationTask: PaginationTask, private val paginationTask: PaginationTask,
cryptoService: CryptoService, private val cryptoService: CryptoService,
private val allowedTypes: List<String>? private val timelineEventMapper: TimelineEventMapper,
) : Timeline { private val settings: TimelineSettings,
private val hiddenReadReceipts: TimelineHiddenReadReceipts
) : Timeline, TimelineHiddenReadReceipts.Delegate {


private companion object { private companion object {
val BACKGROUND_HANDLER = createBackgroundHandler("TIMELINE_DB_THREAD") val BACKGROUND_HANDLER = createBackgroundHandler("TIMELINE_DB_THREAD")
@ -77,6 +98,8 @@ internal class DefaultTimeline(
private val debouncer = Debouncer(mainHandler) private val debouncer = Debouncer(mainHandler)


private lateinit var liveEvents: RealmResults<TimelineEventEntity> private lateinit var liveEvents: RealmResults<TimelineEventEntity>
private lateinit var eventRelations: RealmResults<EventAnnotationsSummaryEntity>

private var roomEntity: RoomEntity? = null private var roomEntity: RoomEntity? = null


private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN private var prevDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN
@ -87,11 +110,8 @@ internal class DefaultTimeline(
private val backwardsPaginationState = AtomicReference(PaginationState()) private val backwardsPaginationState = AtomicReference(PaginationState())
private val forwardsPaginationState = AtomicReference(PaginationState()) private val forwardsPaginationState = AtomicReference(PaginationState())



private val timelineID = UUID.randomUUID().toString() private val timelineID = UUID.randomUUID().toString()


private lateinit var eventRelations: RealmResults<EventAnnotationsSummaryEntity>

private val eventDecryptor = TimelineEventDecryptor(realmConfiguration, timelineID, cryptoService) private val eventDecryptor = TimelineEventDecryptor(realmConfiguration, timelineID, cryptoService)


private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<TimelineEventEntity>> { results, changeSet -> private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<TimelineEventEntity>> { results, changeSet ->
@ -130,9 +150,9 @@ internal class DefaultTimeline(
val eventEntity = results[index] val eventEntity = results[index]
eventEntity?.eventId?.let { eventId -> eventEntity?.eventId?.let { eventId ->
builtEventsIdMap[eventId]?.let { builtIndex -> builtEventsIdMap[eventId]?.let { builtIndex ->
//Update the relation of existing event //Update an existing event
builtEvents[builtIndex]?.let { te -> builtEvents[builtIndex]?.let { te ->
builtEvents[builtIndex] = eventEntity.asDomain() builtEvents[builtIndex] = buildTimelineEvent(eventEntity)
hasChanged = true hasChanged = true
} }
} }
@ -162,32 +182,6 @@ internal class DefaultTimeline(
postSnapshot() postSnapshot()
} }


// private val newSessionListener = object : NewSessionListener {
// override fun onNewSession(roomId: String?, senderKey: String, sessionId: String) {
// if (roomId == this@DefaultTimeline.roomId) {
// Timber.v("New session id detected for this room")
// BACKGROUND_HANDLER.post {
// val realm = backgroundRealm.get()
// var hasChange = false
// builtEvents.forEachIndexed { index, timelineEvent ->
// if (timelineEvent.isEncrypted()) {
// val eventContent = timelineEvent.root.content.toModel<EncryptedEventContent>()
// if (eventContent?.sessionId == sessionId
// && (timelineEvent.root.mClearEvent == null || timelineEvent.root.mCryptoError != null)) {
// //we need to rebuild this event
// EventEntity.where(realm, eventId = timelineEvent.root.eventId!!).findFirst()?.let {
// //builtEvents[index] = timelineEventFactory.create(it, realm)
// hasChange = true
// }
// }
// }
// }
// if (hasChange) postSnapshot()
// }
// }
// }
//
// }


// Public methods ****************************************************************************** // Public methods ******************************************************************************


@ -234,15 +228,20 @@ internal class DefaultTimeline(
} }


liveEvents = buildEventQuery(realm) liveEvents = buildEventQuery(realm)
.filterEventsWithSettings()
.sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.DESCENDING) .sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.DESCENDING)
.findAllAsync() .findAllAsync()
.also { it.addChangeListener(eventsChangeListener) } .also { it.addChangeListener(eventsChangeListener) }


isReady.set(true)

eventRelations = EventAnnotationsSummaryEntity.whereInRoom(realm, roomId) eventRelations = EventAnnotationsSummaryEntity.whereInRoom(realm, roomId)
.findAllAsync() .findAllAsync()
.also { it.addChangeListener(relationsListener) } .also { it.addChangeListener(relationsListener) }

if (settings.buildReadReceipts) {
hiddenReadReceipts.start(realm, liveEvents, this)
}

isReady.set(true)
} }
} }
} }
@ -256,6 +255,9 @@ internal class DefaultTimeline(
roomEntity?.sendingTimelineEvents?.removeAllChangeListeners() roomEntity?.sendingTimelineEvents?.removeAllChangeListeners()
eventRelations.removeAllChangeListeners() eventRelations.removeAllChangeListeners()
liveEvents.removeAllChangeListeners() liveEvents.removeAllChangeListeners()
if (settings.buildReadReceipts) {
hiddenReadReceipts.dispose()
}
backgroundRealm.getAndSet(null).also { backgroundRealm.getAndSet(null).also {
it.close() it.close()
} }
@ -267,6 +269,22 @@ internal class DefaultTimeline(
return hasMoreInCache(direction) || !hasReachedEnd(direction) return hasMoreInCache(direction) || !hasReachedEnd(direction)
} }


// TimelineHiddenReadReceipts.Delegate

override fun rebuildEvent(eventId: String, readReceipts: List<ReadReceipt>): Boolean {
return builtEventsIdMap[eventId]?.let { builtIndex ->
//Update the relation of existing event
builtEvents[builtIndex]?.let { te ->
builtEvents[builtIndex] = te.copy(readReceipts = readReceipts)
true
}
} ?: false
}

override fun onReadReceiptsUpdated() {
postSnapshot()
}

// Private methods ***************************************************************************** // Private methods *****************************************************************************


private fun hasMoreInCache(direction: Timeline.Direction): Boolean { private fun hasMoreInCache(direction: Timeline.Direction): Boolean {
@ -329,9 +347,11 @@ internal class DefaultTimeline(
val sendingEvents = ArrayList<TimelineEvent>() val sendingEvents = ArrayList<TimelineEvent>()
if (hasReachedEnd(Timeline.Direction.FORWARDS)) { if (hasReachedEnd(Timeline.Direction.FORWARDS)) {
roomEntity?.sendingTimelineEvents roomEntity?.sendingTimelineEvents
?.filter { allowedTypes?.contains(it.root?.type) ?: false } ?.where()
?.filterEventsWithSettings()
?.findAll()
?.forEach { ?.forEach {
sendingEvents.add(it.asDomain()) sendingEvents.add(timelineEventMapper.map(it))
} }
} }
return sendingEvents return sendingEvents
@ -378,7 +398,7 @@ internal class DefaultTimeline(
if (initialEventId != null && shouldFetchInitialEvent) { if (initialEventId != null && shouldFetchInitialEvent) {
fetchEvent(initialEventId) fetchEvent(initialEventId)
} else { } else {
val count = Math.min(INITIAL_LOAD_SIZE, liveEvents.size) val count = Math.min(settings.initialSize, liveEvents.size)
if (isLive) { if (isLive) {
paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count) paginateInternal(initialDisplayIndex, Timeline.Direction.BACKWARDS, count)
} else { } else {
@ -463,7 +483,8 @@ internal class DefaultTimeline(
nextDisplayIndex = offsetIndex + 1 nextDisplayIndex = offsetIndex + 1
} }
offsetResults.forEach { eventEntity -> offsetResults.forEach { eventEntity ->
val timelineEvent = eventEntity.asDomain()
val timelineEvent = buildTimelineEvent(eventEntity)


if (timelineEvent.isEncrypted() if (timelineEvent.isEncrypted()
&& timelineEvent.root.mxDecryptionResult == null) { && timelineEvent.root.mxDecryptionResult == null) {
@ -481,6 +502,12 @@ internal class DefaultTimeline(
return offsetResults.size return offsetResults.size
} }


private fun buildTimelineEvent(eventEntity: TimelineEventEntity) = timelineEventMapper.map(
timelineEventEntity = eventEntity,
buildReadReceipts = settings.buildReadReceipts,
correctedReadReceipts = hiddenReadReceipts.correctedReadReceipts(eventEntity.eventId)
)

/** /**
* This has to be called on TimelineThread as it access realm live results * This has to be called on TimelineThread as it access realm live results
*/ */
@ -498,7 +525,6 @@ internal class DefaultTimeline(
.greaterThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, startDisplayIndex) .greaterThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, startDisplayIndex)
} }
return offsetQuery return offsetQuery
.filterAllowedTypes()
.limit(count) .limit(count)
.findAll() .findAll()
} }
@ -557,16 +583,20 @@ internal class DefaultTimeline(
} else { } else {
sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.ASCENDING) sort(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, Sort.ASCENDING)
} }
.filterAllowedTypes() .filterEventsWithSettings()
.findFirst() .findFirst()
} }


private fun RealmQuery<TimelineEventEntity>.filterAllowedTypes(): RealmQuery<TimelineEventEntity> { private fun RealmQuery<TimelineEventEntity>.filterEventsWithSettings(): RealmQuery<TimelineEventEntity> {
if (allowedTypes != null) { if (settings.filterTypes) {
`in`(TimelineEventEntityFields.ROOT.TYPE, allowedTypes.toTypedArray()) `in`(TimelineEventEntityFields.ROOT.TYPE, settings.allowedTypes.toTypedArray())
}
if (settings.filterEdits) {
not().like(TimelineEventEntityFields.ROOT.CONTENT, FilterContent.EDIT_TYPE)
} }
return this return this
} }

} }


private data class PaginationState( private data class PaginationState(

View File

@ -23,8 +23,10 @@ import im.vector.matrix.android.api.session.crypto.CryptoService
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.matrix.android.api.session.room.timeline.TimelineService import im.vector.matrix.android.api.session.room.timeline.TimelineService
import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
import im.vector.matrix.android.internal.database.RealmLiveData import im.vector.matrix.android.internal.database.RealmLiveData
import im.vector.matrix.android.internal.database.mapper.asDomain import im.vector.matrix.android.internal.database.mapper.ReadReceiptsSummaryMapper
import im.vector.matrix.android.internal.database.mapper.TimelineEventMapper
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.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
@ -36,10 +38,12 @@ internal class DefaultTimelineService @Inject constructor(private val roomId: St
private val taskExecutor: TaskExecutor, private val taskExecutor: TaskExecutor,
private val contextOfEventTask: GetContextOfEventTask, private val contextOfEventTask: GetContextOfEventTask,
private val cryptoService: CryptoService, private val cryptoService: CryptoService,
private val paginationTask: PaginationTask private val paginationTask: PaginationTask,
private val timelineEventMapper: TimelineEventMapper,
private val readReceiptsSummaryMapper: ReadReceiptsSummaryMapper
) : TimelineService { ) : TimelineService {


override fun createTimeline(eventId: String?, allowedTypes: List<String>?): Timeline { override fun createTimeline(eventId: String?, settings: TimelineSettings): Timeline {
return DefaultTimeline(roomId, return DefaultTimeline(roomId,
eventId, eventId,
monarchy.realmConfiguration, monarchy.realmConfiguration,
@ -47,7 +51,10 @@ internal class DefaultTimelineService @Inject constructor(private val roomId: St
contextOfEventTask, contextOfEventTask,
paginationTask, paginationTask,
cryptoService, cryptoService,
allowedTypes) timelineEventMapper,
settings,
TimelineHiddenReadReceipts(readReceiptsSummaryMapper, roomId, settings)
)
} }


override fun getTimeLineEvent(eventId: String): TimelineEvent? { override fun getTimeLineEvent(eventId: String): TimelineEvent? {
@ -55,7 +62,7 @@ internal class DefaultTimelineService @Inject constructor(private val roomId: St
.fetchCopyMap({ .fetchCopyMap({
TimelineEventEntity.where(it, eventId = eventId).findFirst() TimelineEventEntity.where(it, eventId = eventId).findFirst()
}, { entity, realm -> }, { entity, realm ->
entity.asDomain() timelineEventMapper.map(entity)
}) })
} }


@ -63,8 +70,8 @@ internal class DefaultTimelineService @Inject constructor(private val roomId: St
val liveData = RealmLiveData(monarchy.realmConfiguration) { val liveData = RealmLiveData(monarchy.realmConfiguration) {
TimelineEventEntity.where(it, eventId = eventId) TimelineEventEntity.where(it, eventId = eventId)
} }
return Transformations.map(liveData) { return Transformations.map(liveData) { events ->
it.firstOrNull()?.asDomain() events.firstOrNull()?.let { timelineEventMapper.map(it) }
} }
} }



View File

@ -0,0 +1,153 @@
/*
* 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 android.util.SparseArray
import im.vector.matrix.android.api.session.room.model.ReadReceipt
import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
import im.vector.matrix.android.internal.database.mapper.ReadReceiptsSummaryMapper
import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntity
import im.vector.matrix.android.internal.database.model.ReadReceiptsSummaryEntityFields
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.whereInRoom
import io.realm.OrderedRealmCollectionChangeListener
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.RealmResults

/**
* This class is responsible for handling the read receipts for hidden events (check [TimelineSettings] to see filtering).
* When an hidden event has read receipts, we want to transfer these read receipts 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 TimelineHiddenReadReceipts constructor(private val readReceiptsSummaryMapper: ReadReceiptsSummaryMapper,
private val roomId: String,
private val settings: TimelineSettings) {

interface Delegate {
fun rebuildEvent(eventId: String, readReceipts: List<ReadReceipt>): Boolean
fun onReadReceiptsUpdated()
}

private val correctedReadReceiptsEventByIndex = SparseArray<String>()
private val correctedReadReceiptsByEvent = HashMap<String, MutableList<ReadReceipt>>()

private lateinit var hiddenReadReceipts: RealmResults<ReadReceiptsSummaryEntity>
private lateinit var liveEvents: RealmResults<TimelineEventEntity>
private lateinit var delegate: Delegate

private val hiddenReadReceiptsListener = OrderedRealmCollectionChangeListener<RealmResults<ReadReceiptsSummaryEntity>> { collection, changeSet ->
var hasChange = false
// Deletion here means we don't have any readReceipts for the given hidden events
changeSet.deletions.forEach {
val eventId = correctedReadReceiptsEventByIndex[it]
val timelineEvent = liveEvents.where()
.equalTo(TimelineEventEntityFields.EVENT_ID, eventId)
.findFirst()

// We are rebuilding the corresponding event with only his own RR
val readReceipts = readReceiptsSummaryMapper.map(timelineEvent?.readReceipts)
hasChange = delegate.rebuildEvent(eventId, readReceipts) || hasChange
}
correctedReadReceiptsEventByIndex.clear()
correctedReadReceiptsByEvent.clear()
hiddenReadReceipts.forEachIndexed { index, summary ->
val timelineEvent = summary?.timelineEvent?.firstOrNull()
val displayIndex = timelineEvent?.root?.displayIndex
if (displayIndex != null) {
// Then we are looking for the first displayable event after the hidden one
val firstDisplayedEvent = liveEvents.where()
.lessThanOrEqualTo(TimelineEventEntityFields.ROOT.DISPLAY_INDEX, displayIndex)
.findFirst()

// If we find one, we should
if (firstDisplayedEvent != null) {
correctedReadReceiptsEventByIndex.put(index, firstDisplayedEvent.eventId)
correctedReadReceiptsByEvent
.getOrPut(firstDisplayedEvent.eventId, {
ArrayList(readReceiptsSummaryMapper.map(firstDisplayedEvent.readReceipts))
})
.addAll(readReceiptsSummaryMapper.map(summary))
}
}
}
if (correctedReadReceiptsByEvent.isNotEmpty()) {
correctedReadReceiptsByEvent.forEach { (eventId, correctedReadReceipts) ->
val sortedReadReceipts = correctedReadReceipts.sortedByDescending {
it.originServerTs
}
hasChange = delegate.rebuildEvent(eventId, sortedReadReceipts) || hasChange
}
}
if (hasChange) {
delegate.onReadReceiptsUpdated()
}
}

/**
* Start the realm query subscription. Has to be called on an HandlerThread
*/
fun start(realm: Realm, liveEvents: RealmResults<TimelineEventEntity>, delegate: Delegate) {
this.liveEvents = liveEvents
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).
this.hiddenReadReceipts = ReadReceiptsSummaryEntity.whereInRoom(realm, roomId)
.isNotEmpty(ReadReceiptsSummaryEntityFields.TIMELINE_EVENT)
.isNotEmpty(ReadReceiptsSummaryEntityFields.READ_RECEIPTS.`$`)
.filterReceiptsWithSettings()
.findAllAsync()
.also { it.addChangeListener(hiddenReadReceiptsListener) }
}

/**
* Dispose the realm query subscription. Has to be called on an HandlerThread
*/
fun dispose() {
this.hiddenReadReceipts.removeAllChangeListeners()
}

/**
* Return the current corrected [ReadReceipt] list for an event, or null
*/
fun correctedReadReceipts(eventId: String?): List<ReadReceipt>? {
return correctedReadReceiptsByEvent[eventId]
}


/**
* We are looking for receipts related to filtered events. So, it's the opposite of [DefaultTimeline.filterEventsWithSettings] method.
*/
private fun RealmQuery<ReadReceiptsSummaryEntity>.filterReceiptsWithSettings(): RealmQuery<ReadReceiptsSummaryEntity> {
beginGroup()
if (settings.filterTypes) {
not().`in`("${ReadReceiptsSummaryEntityFields.TIMELINE_EVENT}.${TimelineEventEntityFields.ROOT.TYPE}", settings.allowedTypes.toTypedArray())
}
if (settings.filterTypes && settings.filterEdits) {
or()
}
if (settings.filterEdits) {
like("${ReadReceiptsSummaryEntityFields.TIMELINE_EVENT}.${TimelineEventEntityFields.ROOT.CONTENT}", FilterContent.EDIT_TYPE)
}
endGroup()
return this
}


}

View File

@ -17,6 +17,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.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.query.createUnmanaged
import im.vector.matrix.android.internal.database.query.getOrCreate
import im.vector.matrix.android.internal.database.query.where
import io.realm.Realm import io.realm.Realm
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -29,34 +33,70 @@ import javax.inject.Inject
// dict value ts value // dict value ts value
typealias ReadReceiptContent = Map<String, Map<String, Map<String, Map<String, Double>>>> typealias ReadReceiptContent = Map<String, Map<String, Map<String, Map<String, Double>>>>


private const val READ_KEY = "m.read"
private const val TIMESTAMP_KEY = "ts"

internal class ReadReceiptHandler @Inject constructor() { internal class ReadReceiptHandler @Inject constructor() {


fun handle(realm: Realm, roomId: String, content: ReadReceiptContent?) { fun handle(realm: Realm, roomId: String, content: ReadReceiptContent?, isInitialSync: Boolean) {
if (content == null) { if (content == null) {
return return
} }
try { try {
val readReceipts = mapContentToReadReceiptEntities(roomId, content) handleReadReceiptContent(realm, roomId, content, isInitialSync)
realm.insertOrUpdate(readReceipts)
} catch (exception: Exception) { } catch (exception: Exception) {
Timber.e("Fail to handle read receipt for room $roomId") Timber.e("Fail to handle read receipt for room $roomId")
} }
} }


private fun mapContentToReadReceiptEntities(roomId: String, content: ReadReceiptContent): List<ReadReceiptEntity> { private fun handleReadReceiptContent(realm: Realm, roomId: String, content: ReadReceiptContent, isInitialSync: Boolean) {
return content if (isInitialSync) {
.flatMap { (eventId, receiptDict) -> initialSyncStrategy(realm, roomId, content)
receiptDict } else {
.filterKeys { it == "m.read" } incrementalSyncStrategy(realm, roomId, content)
.flatMap { (_, userIdsDict) -> }
userIdsDict.map { (userId, paramsDict) -> }
val ts = paramsDict.filterKeys { it == "ts" }
.values
.firstOrNull() ?: 0.0 private fun initialSyncStrategy(realm: Realm, roomId: String, content: ReadReceiptContent) {
val primaryKey = roomId + userId val readReceiptSummaries = ArrayList<ReadReceiptsSummaryEntity>()
ReadReceiptEntity(primaryKey, userId, eventId, roomId, ts) for ((eventId, receiptDict) in content) {
val userIdsDict = receiptDict[READ_KEY] ?: continue
val readReceiptsSummary = ReadReceiptsSummaryEntity(eventId = eventId, roomId = roomId)

for ((userId, paramsDict) in userIdsDict) {
val ts = paramsDict[TIMESTAMP_KEY] ?: 0.0
val receiptEntity = ReadReceiptEntity.createUnmanaged(roomId, eventId, userId, ts)
readReceiptsSummary.readReceipts.add(receiptEntity)
}
readReceiptSummaries.add(readReceiptsSummary)
}
realm.insertOrUpdate(readReceiptSummaries)
}

private fun incrementalSyncStrategy(realm: Realm, roomId: String, content: ReadReceiptContent) {
for ((eventId, receiptDict) in content) {
val userIdsDict = receiptDict[READ_KEY] ?: continue
val readReceiptsSummary = ReadReceiptsSummaryEntity.where(realm, eventId).findFirst()
?: realm.createObject(ReadReceiptsSummaryEntity::class.java, eventId).apply {
this.roomId = roomId
}

for ((userId, paramsDict) in userIdsDict) {
val ts = paramsDict[TIMESTAMP_KEY] ?: 0.0
val receiptEntity = ReadReceiptEntity.getOrCreate(realm, roomId, userId)
// ensure new ts is superior to the previous one
if (ts > receiptEntity.originServerTs) {
ReadReceiptsSummaryEntity.where(realm, receiptEntity.eventId).findFirst()?.also {
it.readReceipts.remove(receiptEntity)
}
receiptEntity.eventId = eventId
receiptEntity.originServerTs = ts
readReceiptsSummary.readReceipts.add(receiptEntity)
} }
} }
} }
} }


} }

View File

@ -62,11 +62,11 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
data class LEFT(val data: Map<String, RoomSync>) : HandlingStrategy() data class LEFT(val data: Map<String, RoomSync>) : HandlingStrategy()
} }


fun handle(roomsSyncResponse: RoomsSyncResponse, reporter: DefaultInitialSyncProgressService? = null) { fun handle(roomsSyncResponse: RoomsSyncResponse, isInitialSync: Boolean, reporter: DefaultInitialSyncProgressService? = null) {
monarchy.runTransactionSync { realm -> monarchy.runTransactionSync { realm ->
handleRoomSync(realm, HandlingStrategy.JOINED(roomsSyncResponse.join), reporter) handleRoomSync(realm, HandlingStrategy.JOINED(roomsSyncResponse.join), isInitialSync, reporter)
handleRoomSync(realm, HandlingStrategy.INVITED(roomsSyncResponse.invite), reporter) handleRoomSync(realm, HandlingStrategy.INVITED(roomsSyncResponse.invite), isInitialSync, reporter)
handleRoomSync(realm, HandlingStrategy.LEFT(roomsSyncResponse.leave), reporter) handleRoomSync(realm, HandlingStrategy.LEFT(roomsSyncResponse.leave), isInitialSync, reporter)
} }


//handle event for bing rule checks //handle event for bing rule checks
@ -89,12 +89,12 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch


// PRIVATE METHODS ***************************************************************************** // PRIVATE METHODS *****************************************************************************


private fun handleRoomSync(realm: Realm, handlingStrategy: HandlingStrategy, reporter: DefaultInitialSyncProgressService?) { private fun handleRoomSync(realm: Realm, handlingStrategy: HandlingStrategy, isInitialSync: Boolean, reporter: DefaultInitialSyncProgressService?) {


val rooms = when (handlingStrategy) { val rooms = when (handlingStrategy) {
is HandlingStrategy.JOINED -> is HandlingStrategy.JOINED ->
handlingStrategy.data.mapWithProgress(reporter, R.string.initial_sync_start_importing_account_joined_rooms, 0.6f) { handlingStrategy.data.mapWithProgress(reporter, R.string.initial_sync_start_importing_account_joined_rooms, 0.6f) {
handleJoinedRoom(realm, it.key, it.value) handleJoinedRoom(realm, it.key, it.value, isInitialSync)
} }
is HandlingStrategy.INVITED -> is HandlingStrategy.INVITED ->
handlingStrategy.data.mapWithProgress(reporter, R.string.initial_sync_start_importing_account_invited_rooms, 0.4f) { handlingStrategy.data.mapWithProgress(reporter, R.string.initial_sync_start_importing_account_invited_rooms, 0.4f) {
@ -112,10 +112,19 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch


private fun handleJoinedRoom(realm: Realm, private fun handleJoinedRoom(realm: Realm,
roomId: String, roomId: String,
roomSync: RoomSync): RoomEntity { roomSync: RoomSync,
isInitalSync: Boolean): RoomEntity {


Timber.v("Handle join sync for room $roomId") Timber.v("Handle join sync for room $roomId")


if (roomSync.ephemeral != null && roomSync.ephemeral.events.isNotEmpty()) {
handleEphemeral(realm, roomId, roomSync.ephemeral, isInitalSync)
}

if (roomSync.accountData != null && roomSync.accountData.events.isNullOrEmpty().not()) {
handleRoomAccountDataEvents(realm, roomId, roomSync.accountData)
}

val roomEntity = RoomEntity.where(realm, roomId).findFirst() val roomEntity = RoomEntity.where(realm, roomId).findFirst()
?: realm.createObject(roomId) ?: realm.createObject(roomId)


@ -150,14 +159,6 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
roomEntity.addOrUpdate(chunkEntity) roomEntity.addOrUpdate(chunkEntity)
} }
roomSummaryUpdater.update(realm, roomId, Membership.JOIN, roomSync.summary, roomSync.unreadNotifications) roomSummaryUpdater.update(realm, roomId, Membership.JOIN, roomSync.summary, roomSync.unreadNotifications)

if (roomSync.ephemeral != null && roomSync.ephemeral.events.isNotEmpty()) {
handleEphemeral(realm, roomId, roomSync.ephemeral)
}

if (roomSync.accountData != null && roomSync.accountData.events.isNullOrEmpty().not()) {
handleRoomAccountDataEvents(realm, roomId, roomSync.accountData)
}
return roomEntity return roomEntity
} }


@ -233,17 +234,21 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
} }




@Suppress("UNCHECKED_CAST")
private fun handleEphemeral(realm: Realm, private fun handleEphemeral(realm: Realm,
roomId: String, roomId: String,
ephemeral: RoomSyncEphemeral) { ephemeral: RoomSyncEphemeral,
ephemeral.events isInitalSync: Boolean) {
.filter { it.getClearType() == EventType.RECEIPT } for (event in ephemeral.events) {
.map { it.content.toModel<ReadReceiptContent>() } if (event.type != EventType.RECEIPT) continue
.forEach { readReceiptHandler.handle(realm, roomId, it) } val readReceiptContent = event.content as? ReadReceiptContent ?: continue
readReceiptHandler.handle(realm, roomId, readReceiptContent, isInitalSync)
}
} }


private fun handleRoomAccountDataEvents(realm: Realm, roomId: String, accountData: RoomSyncAccountData) { private fun handleRoomAccountDataEvents(realm: Realm, roomId: String, accountData: RoomSyncAccountData) {
accountData.events accountData.events
.asSequence()
.filter { it.getClearType() == EventType.TAG } .filter { it.getClearType() == EventType.TAG }
.map { it.content.toModel<RoomTagContent>() } .map { it.content.toModel<RoomTagContent>() }
.forEach { roomTagHandler.handle(realm, roomId, it) } .forEach { roomTagHandler.handle(realm, roomId, it) }

View File

@ -66,7 +66,7 @@ internal class SyncResponseHandler @Inject constructor(private val roomSyncHandl


reportSubtask(reporter, R.string.initial_sync_start_importing_account_rooms, 100, 0.7f) { reportSubtask(reporter, R.string.initial_sync_start_importing_account_rooms, 100, 0.7f) {
if (syncResponse.rooms != null) { if (syncResponse.rooms != null) {
roomSyncHandler.handle(syncResponse.rooms, reporter) roomSyncHandler.handle(syncResponse.rooms, isInitialSync, reporter)
} }
} }
}.also { }.also {

View File

@ -14,15 +14,18 @@
* limitations under the License. * limitations under the License.
*/ */


package im.vector.riotx.features.home.room.detail.timeline.helper package im.vector.riotx.core.date


import android.content.Context
import android.text.format.DateUtils
import im.vector.riotx.core.resources.LocaleProvider import im.vector.riotx.core.resources.LocaleProvider
import org.threeten.bp.LocalDateTime import org.threeten.bp.LocalDateTime
import org.threeten.bp.format.DateTimeFormatter import org.threeten.bp.format.DateTimeFormatter
import javax.inject.Inject import javax.inject.Inject




class TimelineDateFormatter @Inject constructor (private val localeProvider: LocaleProvider) { class VectorDateFormatter @Inject constructor(private val context: Context,
private val localeProvider: LocaleProvider) {


private val messageHourFormatter by lazy { private val messageHourFormatter by lazy {
DateTimeFormatter.ofPattern("H:mm", localeProvider.current()) DateTimeFormatter.ofPattern("H:mm", localeProvider.current())
@ -39,4 +42,16 @@ class TimelineDateFormatter @Inject constructor (private val localeProvider: Loc
return messageDayFormatter.format(localDateTime) return messageDayFormatter.format(localDateTime)
} }


fun formatRelativeDateTime(time: Long?): String {
if (time == null) {
return ""
}
return DateUtils.getRelativeDateTimeString(context,
time,
DateUtils.DAY_IN_MILLIS,
2 * DateUtils.DAY_IN_MILLIS,
DateUtils.FORMAT_SHOW_WEEKDAY
).toString()
}

} }

View File

@ -41,7 +41,12 @@ import im.vector.riotx.features.home.createdirect.CreateDirectRoomDirectoryUsers
import im.vector.riotx.features.home.createdirect.CreateDirectRoomKnownUsersFragment import im.vector.riotx.features.home.createdirect.CreateDirectRoomKnownUsersFragment
import im.vector.riotx.features.home.group.GroupListFragment import im.vector.riotx.features.home.group.GroupListFragment
import im.vector.riotx.features.home.room.detail.RoomDetailFragment import im.vector.riotx.features.home.room.detail.RoomDetailFragment
import im.vector.riotx.features.home.room.detail.timeline.action.* import im.vector.riotx.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsBottomSheet
import im.vector.riotx.features.home.room.detail.timeline.action.MessageMenuFragment
import im.vector.riotx.features.home.room.detail.timeline.action.QuickReactionFragment
import im.vector.riotx.features.home.room.detail.timeline.action.ViewEditHistoryBottomSheet
import im.vector.riotx.features.home.room.detail.timeline.action.ViewReactionBottomSheet
import im.vector.riotx.features.home.room.filtered.FilteredRoomsActivity import im.vector.riotx.features.home.room.filtered.FilteredRoomsActivity
import im.vector.riotx.features.home.room.list.RoomListFragment import im.vector.riotx.features.home.room.list.RoomListFragment
import im.vector.riotx.features.invite.VectorInviteView import im.vector.riotx.features.invite.VectorInviteView
@ -168,6 +173,8 @@ interface ScreenComponent {


fun inject(createDirectRoomActivity: CreateDirectRoomActivity) fun inject(createDirectRoomActivity: CreateDirectRoomActivity)


fun inject(displayReadReceiptsBottomSheet: DisplayReadReceiptsBottomSheet)

@Component.Factory @Component.Factory
interface Factory { interface Factory {
fun create(vectorComponent: VectorComponent, fun create(vectorComponent: VectorComponent,

View File

@ -29,7 +29,11 @@ import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupSettingsVie
import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupSettingsViewModel_AssistedFactory import im.vector.riotx.features.crypto.keysbackup.settings.KeysBackupSettingsViewModel_AssistedFactory
import im.vector.riotx.features.crypto.keysbackup.setup.KeysBackupSetupSharedViewModel import im.vector.riotx.features.crypto.keysbackup.setup.KeysBackupSetupSharedViewModel
import im.vector.riotx.features.crypto.verification.SasVerificationViewModel import im.vector.riotx.features.crypto.verification.SasVerificationViewModel
import im.vector.riotx.features.home.* import im.vector.riotx.features.home.HomeActivityViewModel
import im.vector.riotx.features.home.HomeActivityViewModel_AssistedFactory
import im.vector.riotx.features.home.HomeDetailViewModel
import im.vector.riotx.features.home.HomeDetailViewModel_AssistedFactory
import im.vector.riotx.features.home.HomeNavigationViewModel
import im.vector.riotx.features.home.createdirect.CreateDirectRoomNavigationViewModel import im.vector.riotx.features.home.createdirect.CreateDirectRoomNavigationViewModel
import im.vector.riotx.features.home.createdirect.CreateDirectRoomViewModel import im.vector.riotx.features.home.createdirect.CreateDirectRoomViewModel
import im.vector.riotx.features.home.createdirect.CreateDirectRoomViewModel_AssistedFactory import im.vector.riotx.features.home.createdirect.CreateDirectRoomViewModel_AssistedFactory
@ -39,7 +43,16 @@ import im.vector.riotx.features.home.room.detail.RoomDetailViewModel
import im.vector.riotx.features.home.room.detail.RoomDetailViewModel_AssistedFactory import im.vector.riotx.features.home.room.detail.RoomDetailViewModel_AssistedFactory
import im.vector.riotx.features.home.room.detail.composer.TextComposerViewModel import im.vector.riotx.features.home.room.detail.composer.TextComposerViewModel
import im.vector.riotx.features.home.room.detail.composer.TextComposerViewModel_AssistedFactory import im.vector.riotx.features.home.room.detail.composer.TextComposerViewModel_AssistedFactory
import im.vector.riotx.features.home.room.detail.timeline.action.* import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsViewModel
import im.vector.riotx.features.home.room.detail.timeline.action.MessageActionsViewModel_AssistedFactory
import im.vector.riotx.features.home.room.detail.timeline.action.MessageMenuViewModel
import im.vector.riotx.features.home.room.detail.timeline.action.MessageMenuViewModel_AssistedFactory
import im.vector.riotx.features.home.room.detail.timeline.action.QuickReactionViewModel
import im.vector.riotx.features.home.room.detail.timeline.action.QuickReactionViewModel_AssistedFactory
import im.vector.riotx.features.home.room.detail.timeline.action.ViewEditHistoryViewModel
import im.vector.riotx.features.home.room.detail.timeline.action.ViewEditHistoryViewModel_AssistedFactory
import im.vector.riotx.features.home.room.detail.timeline.action.ViewReactionViewModel
import im.vector.riotx.features.home.room.detail.timeline.action.ViewReactionViewModel_AssistedFactory
import im.vector.riotx.features.home.room.list.RoomListViewModel import im.vector.riotx.features.home.room.list.RoomListViewModel
import im.vector.riotx.features.home.room.list.RoomListViewModel_AssistedFactory import im.vector.riotx.features.home.room.list.RoomListViewModel_AssistedFactory
import im.vector.riotx.features.reactions.EmojiChooserViewModel import im.vector.riotx.features.reactions.EmojiChooserViewModel

View File

@ -161,7 +161,6 @@ abstract class VectorBaseActivity : BaseMvRxActivity(), HasScreenInjector {


override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()

unBinder?.unbind() unBinder?.unbind()
unBinder = null unBinder = null



View File

@ -24,4 +24,9 @@ class UserPreferencesProvider @Inject constructor(private val vectorPreferences:
fun shouldShowHiddenEvents(): Boolean { fun shouldShowHiddenEvents(): Boolean {
return vectorPreferences.shouldShowHiddenEvents() return vectorPreferences.shouldShowHiddenEvents()
} }

fun shouldShowReadReceipts(): Boolean {
return vectorPreferences.showReadReceipts()
}

} }

View File

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */


package im.vector.riotx.core.platform package im.vector.riotx.core.ui.views


import android.content.Context import android.content.Context
import android.graphics.Color import android.graphics.Color
@ -32,10 +32,7 @@ import androidx.core.content.ContextCompat
import butterknife.BindView import butterknife.BindView
import butterknife.ButterKnife import butterknife.ButterKnife
import im.vector.matrix.android.api.failure.MatrixError import im.vector.matrix.android.api.failure.MatrixError
import im.vector.matrix.android.api.permalinks.PermalinkFactory
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.toModel
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.error.ResourceLimitErrorFormatter import im.vector.riotx.core.error.ResourceLimitErrorFormatter
import im.vector.riotx.features.themes.ThemeUtils import im.vector.riotx.features.themes.ThemeUtils

View File

@ -0,0 +1,80 @@
/*
* 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.widget.ImageView
import android.widget.LinearLayout
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import butterknife.ButterKnife
import im.vector.riotx.R
import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData
import kotlinx.android.synthetic.main.view_read_receipts.view.*

private const val MAX_RECEIPT_DISPLAYED = 5

class ReadReceiptsView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {

private val receiptAvatars: List<ImageView> by lazy {
listOf(receiptAvatar1, receiptAvatar2, receiptAvatar3, receiptAvatar4, receiptAvatar5)
}

init {
setupView()
}

private fun setupView() {
inflate(context, R.layout.view_read_receipts, this)
ButterKnife.bind(this)
}

fun render(readReceipts: List<ReadReceiptData>, avatarRenderer: AvatarRenderer, clickListener: OnClickListener) {
setOnClickListener(clickListener)
if (readReceipts.isNotEmpty()) {
isVisible = true
for (index in 0 until MAX_RECEIPT_DISPLAYED) {
val receiptData = readReceipts.getOrNull(index)
if (receiptData == null) {
receiptAvatars[index].visibility = View.INVISIBLE
} else {
receiptAvatars[index].visibility = View.VISIBLE
avatarRenderer.render(receiptData.avatarUrl, receiptData.userId, receiptData.displayName, receiptAvatars[index])
}
}
if (readReceipts.size > MAX_RECEIPT_DISPLAYED) {
receiptMore.visibility = View.VISIBLE
receiptMore.text = context.getString(
R.string.x_plus, readReceipts.size - MAX_RECEIPT_DISPLAYED
)
} else {
receiptMore.visibility = View.GONE
}
} else {
isVisible = false
}
}

}

View File

@ -77,7 +77,7 @@ import im.vector.riotx.core.extensions.observeEvent
import im.vector.riotx.core.extensions.setTextOrHide import im.vector.riotx.core.extensions.setTextOrHide
import im.vector.riotx.core.files.addEntryToDownloadManager import im.vector.riotx.core.files.addEntryToDownloadManager
import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.platform.NotificationAreaView import im.vector.riotx.core.ui.views.NotificationAreaView
import im.vector.riotx.core.platform.VectorBaseFragment import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.utils.* import im.vector.riotx.core.utils.*
import im.vector.riotx.features.autocomplete.command.AutocompleteCommandPresenter import im.vector.riotx.features.autocomplete.command.AutocompleteCommandPresenter
@ -92,6 +92,7 @@ import im.vector.riotx.features.home.room.detail.composer.TextComposerActions
import im.vector.riotx.features.home.room.detail.composer.TextComposerView import im.vector.riotx.features.home.room.detail.composer.TextComposerView
import im.vector.riotx.features.home.room.detail.composer.TextComposerViewModel import im.vector.riotx.features.home.room.detail.composer.TextComposerViewModel
import im.vector.riotx.features.home.room.detail.composer.TextComposerViewState import im.vector.riotx.features.home.room.detail.composer.TextComposerViewState
import im.vector.riotx.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet
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.action.* import im.vector.riotx.features.home.room.detail.timeline.action.*
import im.vector.riotx.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener import im.vector.riotx.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
@ -830,6 +831,11 @@ class RoomDetailFragment :
}) })
} }


override fun onReadReceiptsClicked(readReceipts: List<ReadReceiptData>) {
DisplayReadReceiptsBottomSheet.newInstance(readReceipts)
.show(requireActivity().supportFragmentManager, "DISPLAY_READ_RECEIPTS")
}

// AutocompleteUserPresenter.Callback // AutocompleteUserPresenter.Callback


override fun onQueryUsers(query: CharSequence?) { override fun onQueryUsers(query: CharSequence?) {

View File

@ -43,6 +43,7 @@ import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.message.getFileUrl import im.vector.matrix.android.api.session.room.model.message.getFileUrl
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.TimelineSettings
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
import im.vector.matrix.rx.rx import im.vector.matrix.rx.rx
@ -66,7 +67,7 @@ import java.util.concurrent.TimeUnit




class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: RoomDetailViewState, class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: RoomDetailViewState,
userPreferencesProvider: UserPreferencesProvider, private val userPreferencesProvider: UserPreferencesProvider,
private val vectorPreferences: VectorPreferences, private val vectorPreferences: VectorPreferences,
private val session: Session private val session: Session
) : VectorViewModel<RoomDetailViewState>(initialState) { ) : VectorViewModel<RoomDetailViewState>(initialState) {
@ -75,12 +76,13 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
private val roomId = initialState.roomId private val roomId = initialState.roomId
private val eventId = initialState.eventId private val eventId = initialState.eventId
private val displayedEventsObservable = BehaviorRelay.create<RoomDetailActions.EventDisplayed>() private val displayedEventsObservable = BehaviorRelay.create<RoomDetailActions.EventDisplayed>()
private val allowedTypes = if (userPreferencesProvider.shouldShowHiddenEvents()) { private val timelineSettings = if (userPreferencesProvider.shouldShowHiddenEvents()) {
TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES TimelineSettings(30, false, true, TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES, userPreferencesProvider.shouldShowReadReceipts())
} else { } else {
TimelineDisplayableEvents.DISPLAYABLE_TYPES TimelineSettings(30, true, true, TimelineDisplayableEvents.DISPLAYABLE_TYPES, userPreferencesProvider.shouldShowReadReceipts())
} }
private var timeline = room.createTimeline(eventId, allowedTypes)
private var timeline = room.createTimeline(eventId, timelineSettings)


// Slot to keep a pending action during permission request // Slot to keep a pending action during permission request
var pendingAction: RoomDetailActions? = null var pendingAction: RoomDetailActions? = null
@ -552,7 +554,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} else { } else {
// change timeline // change timeline
timeline.dispose() timeline.dispose()
timeline = room.createTimeline(targetEventId, allowedTypes) timeline = room.createTimeline(targetEventId, timelineSettings)
timeline.start() timeline.start()


withState { withState {

View File

@ -0,0 +1,55 @@
/*
* 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.readreceipts

import android.widget.ImageView
import android.widget.TextView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.airbnb.epoxy.EpoxyModelWithHolder
import im.vector.riotx.R
import im.vector.riotx.core.epoxy.VectorEpoxyHolder
import im.vector.riotx.features.home.AvatarRenderer

@EpoxyModelClass(layout = R.layout.item_display_read_receipt)
abstract class DisplayReadReceiptItem : EpoxyModelWithHolder<DisplayReadReceiptItem.Holder>() {

@EpoxyAttribute var name: String? = null
@EpoxyAttribute var userId: String = ""
@EpoxyAttribute var avatarUrl: String? = null
@EpoxyAttribute var timestamp: CharSequence? = null
@EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer

override fun bind(holder: Holder) {
avatarRenderer.render(avatarUrl, userId, name, holder.avatarView)
holder.displayNameView.text = name ?: userId
timestamp?.let {
holder.timestampView.text = it
holder.timestampView.isVisible = true
} ?: run {
holder.timestampView.isVisible = false
}
}

class Holder : VectorEpoxyHolder() {
val avatarView by bind<ImageView>(R.id.readReceiptAvatar)
val displayNameView by bind<TextView>(R.id.readReceiptName)
val timestampView by bind<TextView>(R.id.readReceiptDate)
}

}

View File

@ -0,0 +1,91 @@
/*
* 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.readreceipts

import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.recyclerview.widget.DividerItemDecoration
import butterknife.BindView
import butterknife.ButterKnife
import com.airbnb.epoxy.EpoxyRecyclerView
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.args
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.features.home.room.detail.timeline.action.VectorBaseBottomSheetDialogFragment
import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData
import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.bottom_sheet_epoxylist_with_title.*
import javax.inject.Inject

@Parcelize
data class DisplayReadReceiptArgs(
val readReceipts: List<ReadReceiptData>
) : Parcelable

/**
* Bottom sheet displaying list of read receipts for a given event ordered by descending timestamp
*/
class DisplayReadReceiptsBottomSheet : VectorBaseBottomSheetDialogFragment() {

@Inject lateinit var epoxyController: DisplayReadReceiptsController

@BindView(R.id.bottom_sheet_display_reactions_list)
lateinit var epoxyRecyclerView: EpoxyRecyclerView

private val displayReadReceiptArgs: DisplayReadReceiptArgs by args()

override fun injectWith(screenComponent: ScreenComponent) {
screenComponent.inject(this)
}

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.bottom_sheet_epoxylist_with_title, container, false)
ButterKnife.bind(this, view)
return view
}

override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
epoxyRecyclerView.setController(epoxyController)
val dividerItemDecoration = DividerItemDecoration(epoxyRecyclerView.context,
LinearLayout.VERTICAL)
epoxyRecyclerView.addItemDecoration(dividerItemDecoration)
bottomSheetTitle.text = getString(R.string.read_receipts_list)
epoxyController.setData(displayReadReceiptArgs.readReceipts)
}

override fun invalidate() {
// we are not using state for this one as it's static
}

companion object {
fun newInstance(readReceipts: List<ReadReceiptData>): DisplayReadReceiptsBottomSheet {
val args = Bundle()
val parcelableArgs = DisplayReadReceiptArgs(
readReceipts
)
args.putParcelable(MvRx.KEY_ARG, parcelableArgs)
return DisplayReadReceiptsBottomSheet().apply { arguments = args }

}
}
}

View File

@ -0,0 +1,48 @@
/*
* 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.readreceipts

import com.airbnb.epoxy.TypedEpoxyController
import im.vector.matrix.android.api.session.Session
import im.vector.riotx.core.date.VectorDateFormatter
import im.vector.riotx.features.home.AvatarRenderer
import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData
import javax.inject.Inject

/**
* Epoxy controller for read receipt event list
*/
class DisplayReadReceiptsController @Inject constructor(private val dateFormatter: VectorDateFormatter,
private val session: Session,
private val avatarRender: AvatarRenderer)
: TypedEpoxyController<List<ReadReceiptData>>() {


override fun buildModels(readReceipts: List<ReadReceiptData>) {
readReceipts.forEach {
val timestamp = dateFormatter.formatRelativeDateTime(it.timestamp)
DisplayReadReceiptItem_()
.id(it.userId)
.userId(it.userId)
.avatarUrl(it.avatarUrl)
.name(it.displayName)
.avatarRenderer(avatarRender)
.timestamp(timestamp)
.addIf(session.myUserId != it.userId, this)
}
}
}

View File

@ -27,6 +27,7 @@ import com.airbnb.epoxy.EpoxyModel
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.epoxy.LoadingItem_ import im.vector.riotx.core.epoxy.LoadingItem_
import im.vector.riotx.core.extensions.localDateTime import im.vector.riotx.core.extensions.localDateTime
import im.vector.riotx.core.resources.UserPreferencesProvider import im.vector.riotx.core.resources.UserPreferencesProvider
@ -37,12 +38,13 @@ import im.vector.riotx.features.home.room.detail.timeline.item.DaySeparatorItem
import im.vector.riotx.features.home.room.detail.timeline.item.DaySeparatorItem_ import im.vector.riotx.features.home.room.detail.timeline.item.DaySeparatorItem_
import im.vector.riotx.features.home.room.detail.timeline.item.MergedHeaderItem import im.vector.riotx.features.home.room.detail.timeline.item.MergedHeaderItem
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData
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 org.threeten.bp.LocalDateTime import org.threeten.bp.LocalDateTime
import javax.inject.Inject import javax.inject.Inject


class TimelineEventController @Inject constructor(private val dateFormatter: TimelineDateFormatter, class TimelineEventController @Inject constructor(private val dateFormatter: VectorDateFormatter,
private val timelineItemFactory: TimelineItemFactory, private val timelineItemFactory: TimelineItemFactory,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider, private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val avatarRenderer: AvatarRenderer, private val avatarRenderer: AvatarRenderer,
@ -51,7 +53,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim
userPreferencesProvider: UserPreferencesProvider userPreferencesProvider: UserPreferencesProvider
) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener { ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener {


interface Callback : ReactionPillCallback, AvatarCallback, BaseCallback, UrlClickCallback { interface Callback : BaseCallback, ReactionPillCallback, AvatarCallback, UrlClickCallback, ReadReceiptsCallback {
fun onEventVisible(event: TimelineEvent) fun onEventVisible(event: TimelineEvent)
fun onRoomCreateLinkClicked(url: String) fun onRoomCreateLinkClicked(url: String)
fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View)
@ -77,6 +79,10 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim
fun onMemberNameClicked(informationData: MessageInformationData) fun onMemberNameClicked(informationData: MessageInformationData)
} }


interface ReadReceiptsCallback {
fun onReadReceiptsClicked(readReceipts: List<ReadReceiptData>)
}

interface UrlClickCallback { interface UrlClickCallback {
fun onUrlClicked(url: String): Boolean fun onUrlClicked(url: String): Boolean
fun onUrlLongClicked(url: String): Boolean fun onUrlLongClicked(url: String): Boolean
@ -293,7 +299,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim
// We try to find if one of the item id were used as mergeItemCollapseStates key // We try to find if one of the item id were used as mergeItemCollapseStates key
// => handle case where paginating from mergeable events and we get more // => handle case where paginating from mergeable events and we get more
val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull() val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull()
val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey) ?: true val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey)
?: true
val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState } val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState }
if (isCollapsed) { if (isCollapsed) {
collapsedEventIds.addAll(mergedEventIds) collapsedEventIds.addAll(mergedEventIds)

View File

@ -49,7 +49,7 @@ class ViewEditHistoryBottomSheet : VectorBaseBottomSheetDialogFragment() {
lateinit var epoxyRecyclerView: EpoxyRecyclerView lateinit var epoxyRecyclerView: EpoxyRecyclerView


private val epoxyController by lazy { private val epoxyController by lazy {
ViewEditHistoryEpoxyController(requireContext(), viewModel.timelineDateFormatter, eventHtmlRenderer) ViewEditHistoryEpoxyController(requireContext(), viewModel.dateFormatter, eventHtmlRenderer)
} }


override fun injectWith(screenComponent: ScreenComponent) { override fun injectWith(screenComponent: ScreenComponent) {

View File

@ -33,7 +33,7 @@ import im.vector.riotx.core.ui.list.genericFooterItem
import im.vector.riotx.core.ui.list.genericItem import im.vector.riotx.core.ui.list.genericItem
import im.vector.riotx.core.ui.list.genericItemHeader import im.vector.riotx.core.ui.list.genericItemHeader
import im.vector.riotx.core.ui.list.genericLoaderItem import im.vector.riotx.core.ui.list.genericLoaderItem
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDateFormatter import im.vector.riotx.core.date.VectorDateFormatter
import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.html.EventHtmlRenderer
import me.gujun.android.span.span import me.gujun.android.span.span
import name.fraser.neil.plaintext.diff_match_patch import name.fraser.neil.plaintext.diff_match_patch
@ -44,7 +44,7 @@ import java.util.*
* Epoxy controller for reaction event list * Epoxy controller for reaction event list
*/ */
class ViewEditHistoryEpoxyController(private val context: Context, class ViewEditHistoryEpoxyController(private val context: Context,
val timelineDateFormatter: TimelineDateFormatter, val dateFormatter: VectorDateFormatter,
val eventHtmlRenderer: EventHtmlRenderer) : TypedEpoxyController<ViewEditHistoryViewState>() { val eventHtmlRenderer: EventHtmlRenderer) : TypedEpoxyController<ViewEditHistoryViewState>() {


override fun buildModels(state: ViewEditHistoryViewState) { override fun buildModels(state: ViewEditHistoryViewState) {
@ -84,7 +84,7 @@ class ViewEditHistoryEpoxyController(private val context: Context,
if (lastDate?.get(Calendar.DAY_OF_YEAR) != evDate.get(Calendar.DAY_OF_YEAR)) { if (lastDate?.get(Calendar.DAY_OF_YEAR) != evDate.get(Calendar.DAY_OF_YEAR)) {
//need to display header with day //need to display header with day
val dateString = if (DateUtils.isToday(evDate.timeInMillis)) context.getString(R.string.today) val dateString = if (DateUtils.isToday(evDate.timeInMillis)) context.getString(R.string.today)
else timelineDateFormatter.formatMessageDay(timelineEvent.localDateTime()) else dateFormatter.formatMessageDay(timelineEvent.localDateTime())
genericItemHeader { genericItemHeader {
id(evDate.hashCode()) id(evDate.hashCode())
text(dateString) text(dateString)
@ -136,7 +136,7 @@ class ViewEditHistoryEpoxyController(private val context: Context,
} }
genericItem { genericItem {
id(timelineEvent.eventId) id(timelineEvent.eventId)
title(timelineDateFormatter.formatMessageHour(timelineEvent.localDateTime())) title(dateFormatter.formatMessageHour(timelineEvent.localDateTime()))
description(spannedDiff ?: body) description(spannedDiff ?: body)
} }
} }

View File

@ -27,7 +27,7 @@ import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.isReply import im.vector.matrix.android.api.session.room.model.message.isReply
import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDateFormatter import im.vector.riotx.core.date.VectorDateFormatter
import timber.log.Timber import timber.log.Timber
import java.util.* import java.util.*


@ -46,7 +46,7 @@ data class ViewEditHistoryViewState(
class ViewEditHistoryViewModel @AssistedInject constructor(@Assisted class ViewEditHistoryViewModel @AssistedInject constructor(@Assisted
initialState: ViewEditHistoryViewState, initialState: ViewEditHistoryViewState,
val session: Session, val session: Session,
val timelineDateFormatter: TimelineDateFormatter val dateFormatter: VectorDateFormatter
) : VectorViewModel<ViewEditHistoryViewState>(initialState) { ) : VectorViewModel<ViewEditHistoryViewState>(initialState) {


private val roomId = initialState.roomId private val roomId = initialState.roomId

View File

@ -16,16 +16,20 @@


package im.vector.riotx.features.home.room.detail.timeline.action package im.vector.riotx.features.home.room.detail.timeline.action


import com.airbnb.mvrx.* import com.airbnb.mvrx.Async
import com.airbnb.mvrx.FragmentViewModelContext
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.ViewModelContext
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.session.Session import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.model.ReactionAggregatedSummary import im.vector.matrix.android.api.session.room.model.ReactionAggregatedSummary
import im.vector.matrix.rx.RxRoom import im.vector.matrix.rx.RxRoom
import im.vector.riotx.core.extensions.localDateTime
import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.platform.VectorViewModel
import im.vector.riotx.core.utils.isSingleEmoji import im.vector.riotx.core.utils.isSingleEmoji
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDateFormatter import im.vector.riotx.core.date.VectorDateFormatter
import io.reactivex.Observable import io.reactivex.Observable
import io.reactivex.Single import io.reactivex.Single


@ -54,7 +58,7 @@ data class ReactionInfo(
class ViewReactionViewModel @AssistedInject constructor(@Assisted class ViewReactionViewModel @AssistedInject constructor(@Assisted
initialState: DisplayReactionsViewState, initialState: DisplayReactionsViewState,
private val session: Session, private val session: Session,
private val timelineDateFormatter: TimelineDateFormatter private val dateFormatter: VectorDateFormatter
) : VectorViewModel<DisplayReactionsViewState>(initialState) { ) : VectorViewModel<DisplayReactionsViewState>(initialState) {


private val roomId = initialState.roomId private val roomId = initialState.roomId
@ -101,13 +105,13 @@ class ViewReactionViewModel @AssistedInject constructor(@Assisted
.map { .map {
val event = room.getTimeLineEvent(it) val event = room.getTimeLineEvent(it)
?: throw RuntimeException("Your eventId is not valid") ?: throw RuntimeException("Your eventId is not valid")
val localDate = event.root.localDateTime()
ReactionInfo( ReactionInfo(
event.root.eventId!!, event.root.eventId!!,
summary.key, summary.key,
event.root.senderId ?: "", event.root.senderId ?: "",
event.getDisambiguatedDisplayName(), event.getDisambiguatedDisplayName(),
timelineDateFormatter.formatMessageHour(localDate) dateFormatter.formatRelativeDateTime(event.root.originServerTs)

) )
} }
}.toList() }.toList()

View File

@ -18,6 +18,7 @@ package im.vector.riotx.features.home.room.detail.timeline.action


import android.content.Context import android.content.Context
import android.graphics.Typeface import android.graphics.Typeface
import android.text.format.DateUtils
import com.airbnb.epoxy.TypedEpoxyController import com.airbnb.epoxy.TypedEpoxyController
import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Incomplete import com.airbnb.mvrx.Incomplete

View File

@ -28,8 +28,15 @@ import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan 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.MessageAudioContent
import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageEmoteContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageNoticeContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
@ -47,7 +54,19 @@ import im.vector.riotx.features.home.room.detail.timeline.TimelineEventControlle
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar
import im.vector.riotx.features.home.room.detail.timeline.item.* import im.vector.riotx.features.home.room.detail.timeline.item.BlankItem_
import im.vector.riotx.features.home.room.detail.timeline.item.DefaultItem
import im.vector.riotx.features.home.room.detail.timeline.item.DefaultItem_
import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem
import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem_
import im.vector.riotx.features.home.room.detail.timeline.item.MessageImageVideoItem
import im.vector.riotx.features.home.room.detail.timeline.item.MessageImageVideoItem_
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem
import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem_
import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_
import im.vector.riotx.features.home.room.detail.timeline.item.RedactedMessageItem
import im.vector.riotx.features.home.room.detail.timeline.item.RedactedMessageItem_
import im.vector.riotx.features.home.room.detail.timeline.util.MessageInformationDataFactory import im.vector.riotx.features.home.room.detail.timeline.util.MessageInformationDataFactory
import im.vector.riotx.features.html.EventHtmlRenderer import im.vector.riotx.features.html.EventHtmlRenderer
import im.vector.riotx.features.media.ImageContentRenderer import im.vector.riotx.features.media.ImageContentRenderer
@ -65,7 +84,7 @@ class MessageItemFactory @Inject constructor(
private val imageContentRenderer: ImageContentRenderer, private val imageContentRenderer: ImageContentRenderer,
private val messageInformationDataFactory: MessageInformationDataFactory, private val messageInformationDataFactory: MessageInformationDataFactory,
private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder, private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder,
private val userPreferencesProvider: UserPreferencesProvider) { private val noticeItemFactory: NoticeItemFactory) {




fun create(event: TimelineEvent, fun create(event: TimelineEvent,
@ -90,27 +109,8 @@ class MessageItemFactory @Inject constructor(
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
) { ) {
// ignore replace event, the targeted id is already edited // This is an edit event, we should it when debugging as a notice event
if (userPreferencesProvider.shouldShowHiddenEvents()) { return noticeItemFactory.create(event, highlight, callback)
//These are just for debug to display hidden event, they should be filtered out in normal mode
val informationData = MessageInformationData(
eventId = event.root.eventId ?: "?",
senderId = event.root.senderId ?: "",
sendState = event.root.sendState,
time = "",
avatarUrl = event.senderAvatar(),
memberName = "",
showInformation = false
)
return NoticeItem_()
.avatarRenderer(avatarRenderer)
.informationData(informationData)
.noticeText("{ \"type\": ${event.root.getClearType()} }")
.highlighted(highlight)
.baseCallback(callback)
} else {
return BlankItem_()
}
} }
// val all = event.root.toContent() // val all = event.root.toContent()
// val ev = all.toModel<Event>() // val ev = all.toModel<Event>()
@ -119,12 +119,10 @@ class MessageItemFactory @Inject constructor(
informationData, informationData,
highlight, highlight,
callback) callback)
is MessageTextContent -> buildTextMessageItem(event.root.sendState, is MessageTextContent -> buildTextMessageItem(messageContent,
messageContent,
informationData, informationData,
highlight, highlight,
callback callback)
)
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, highlight, callback) is MessageImageContent -> buildImageMessageItem(messageContent, informationData, highlight, callback)
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback) is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback) is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback)
@ -144,6 +142,7 @@ class MessageItemFactory @Inject constructor(
.informationData(informationData) .informationData(informationData)
.highlighted(highlight) .highlighted(highlight)
.avatarCallback(callback) .avatarCallback(callback)
.readReceiptsCallback(callback)
.filename(messageContent.body) .filename(messageContent.body)
.iconRes(R.drawable.filetype_audio) .iconRes(R.drawable.filetype_audio)
.reactionPillCallback(callback) .reactionPillCallback(callback)
@ -174,6 +173,7 @@ class MessageItemFactory @Inject constructor(
.avatarCallback(callback) .avatarCallback(callback)
.filename(messageContent.body) .filename(messageContent.body)
.reactionPillCallback(callback) .reactionPillCallback(callback)
.readReceiptsCallback(callback)
.emojiTypeFace(emojiCompatFontProvider.typeface) .emojiTypeFace(emojiCompatFontProvider.typeface)
.iconRes(R.drawable.filetype_attachment) .iconRes(R.drawable.filetype_attachment)
.cellClickListener( .cellClickListener(
@ -188,10 +188,6 @@ class MessageItemFactory @Inject constructor(
DebouncedClickListener(View.OnClickListener { _ -> DebouncedClickListener(View.OnClickListener { _ ->
callback?.onFileMessageClicked(informationData.eventId, messageContent) callback?.onFileMessageClicked(informationData.eventId, messageContent)
})) }))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
}
} }


private fun buildNotHandledMessageItem(messageContent: MessageContent, highlight: Boolean): DefaultItem? { private fun buildNotHandledMessageItem(messageContent: MessageContent, highlight: Boolean): DefaultItem? {
@ -229,6 +225,7 @@ class MessageItemFactory @Inject constructor(
.avatarCallback(callback) .avatarCallback(callback)
.mediaData(data) .mediaData(data)
.reactionPillCallback(callback) .reactionPillCallback(callback)
.readReceiptsCallback(callback)
.emojiTypeFace(emojiCompatFontProvider.typeface) .emojiTypeFace(emojiCompatFontProvider.typeface)
.clickListener( .clickListener(
DebouncedClickListener(View.OnClickListener { view -> DebouncedClickListener(View.OnClickListener { view ->
@ -280,6 +277,7 @@ class MessageItemFactory @Inject constructor(
.avatarCallback(callback) .avatarCallback(callback)
.mediaData(thumbnailData) .mediaData(thumbnailData)
.reactionPillCallback(callback) .reactionPillCallback(callback)
.readReceiptsCallback(callback)
.emojiTypeFace(emojiCompatFontProvider.typeface) .emojiTypeFace(emojiCompatFontProvider.typeface)
.cellClickListener( .cellClickListener(
DebouncedClickListener(View.OnClickListener { view -> DebouncedClickListener(View.OnClickListener { view ->
@ -292,8 +290,7 @@ class MessageItemFactory @Inject constructor(
} }
} }


private fun buildTextMessageItem(sendState: SendState, private fun buildTextMessageItem(messageContent: MessageTextContent,
messageContent: MessageTextContent,
informationData: MessageInformationData, informationData: MessageInformationData,
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?): MessageTextItem? { callback: TimelineEventController.Callback?): MessageTextItem? {
@ -320,6 +317,7 @@ class MessageItemFactory @Inject constructor(
.avatarCallback(callback) .avatarCallback(callback)
.urlClickCallback(callback) .urlClickCallback(callback)
.reactionPillCallback(callback) .reactionPillCallback(callback)
.readReceiptsCallback(callback)
.emojiTypeFace(emojiCompatFontProvider.typeface) .emojiTypeFace(emojiCompatFontProvider.typeface)
//click on the text //click on the text
.cellClickListener( .cellClickListener(
@ -386,6 +384,7 @@ class MessageItemFactory @Inject constructor(
.avatarCallback(callback) .avatarCallback(callback)
.reactionPillCallback(callback) .reactionPillCallback(callback)
.urlClickCallback(callback) .urlClickCallback(callback)
.readReceiptsCallback(callback)
.emojiTypeFace(emojiCompatFontProvider.typeface) .emojiTypeFace(emojiCompatFontProvider.typeface)
.memberClickListener( .memberClickListener(
DebouncedClickListener(View.OnClickListener { view -> DebouncedClickListener(View.OnClickListener { view ->
@ -425,6 +424,7 @@ class MessageItemFactory @Inject constructor(
.highlighted(highlight) .highlighted(highlight)
.avatarCallback(callback) .avatarCallback(callback)
.reactionPillCallback(callback) .reactionPillCallback(callback)
.readReceiptsCallback(callback)
.urlClickCallback(callback) .urlClickCallback(callback)
.emojiTypeFace(emojiCompatFontProvider.typeface) .emojiTypeFace(emojiCompatFontProvider.typeface)
.cellClickListener( .cellClickListener(
@ -446,6 +446,7 @@ class MessageItemFactory @Inject constructor(
.informationData(informationData) .informationData(informationData)
.highlighted(highlight) .highlighted(highlight)
.avatarCallback(callback) .avatarCallback(callback)
.readReceiptsCallback(callback)
.cellClickListener( .cellClickListener(
DebouncedClickListener(View.OnClickListener { view -> DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, null, view) callback?.onEventCellClicked(informationData, null, view)

View File

@ -25,23 +25,18 @@ import im.vector.riotx.features.home.room.detail.timeline.helper.senderName
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem
import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_ import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_
import im.vector.riotx.features.home.room.detail.timeline.util.MessageInformationDataFactory
import javax.inject.Inject import javax.inject.Inject


class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEventFormatter, class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEventFormatter,
private val avatarRenderer: AvatarRenderer) { private val avatarRenderer: AvatarRenderer,
private val informationDataFactory: MessageInformationDataFactory) {


fun create(event: TimelineEvent, fun create(event: TimelineEvent,
highlight: Boolean, highlight: 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 = MessageInformationData( val informationData = informationDataFactory.create(event, null)
eventId = event.root.eventId ?: "?",
senderId = event.root.senderId ?: "",
sendState = event.root.sendState,
avatarUrl = event.senderAvatar(),
memberName = event.senderName(),
showInformation = false
)


return NoticeItem_() return NoticeItem_()
.avatarRenderer(avatarRenderer) .avatarRenderer(avatarRenderer)
@ -49,6 +44,7 @@ class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEv
.highlighted(highlight) .highlighted(highlight)
.informationData(informationData) .informationData(informationData)
.baseCallback(callback) .baseCallback(callback)
.readReceiptsCallback(callback)
} }





View File

@ -25,6 +25,7 @@ import im.vector.riotx.features.home.room.detail.timeline.TimelineEventControlle
import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar import im.vector.riotx.features.home.room.detail.timeline.helper.senderAvatar
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_ import im.vector.riotx.features.home.room.detail.timeline.item.NoticeItem_
import im.vector.riotx.features.home.room.detail.timeline.util.MessageInformationDataFactory
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject


@ -33,8 +34,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
private val encryptedItemFactory: EncryptedItemFactory, private val encryptedItemFactory: EncryptedItemFactory,
private val noticeItemFactory: NoticeItemFactory, private val noticeItemFactory: NoticeItemFactory,
private val defaultItemFactory: DefaultItemFactory, private val defaultItemFactory: DefaultItemFactory,
private val roomCreateItemFactory: RoomCreateItemFactory, private val roomCreateItemFactory: RoomCreateItemFactory) {
private val avatarRenderer: AvatarRenderer) {


fun create(event: TimelineEvent, fun create(event: TimelineEvent,
nextEvent: TimelineEvent?, nextEvent: TimelineEvent?,
@ -53,7 +53,9 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
EventType.STATE_HISTORY_VISIBILITY, EventType.STATE_HISTORY_VISIBILITY,
EventType.CALL_INVITE, EventType.CALL_INVITE,
EventType.CALL_HANGUP, EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> noticeItemFactory.create(event, highlight, callback) EventType.CALL_ANSWER,
EventType.REACTION,
EventType.REDACTION -> 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
@ -70,24 +72,9 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
// Unhandled event types (yet) // Unhandled event types (yet)
EventType.STATE_ROOM_THIRD_PARTY_INVITE, EventType.STATE_ROOM_THIRD_PARTY_INVITE,
EventType.STICKER -> defaultItemFactory.create(event, highlight) EventType.STICKER -> defaultItemFactory.create(event, highlight)

else -> { else -> {
//These are just for debug to display hidden event, they should be filtered out in normal mode Timber.v("Type ${event.root.getClearType()} not handled")
val informationData = MessageInformationData( null
eventId = event.root.eventId ?: "?",
senderId = event.root.senderId ?: "",
sendState = event.root.sendState,
time = "",
avatarUrl = event.senderAvatar(),
memberName = "",
showInformation = false
)
NoticeItem_()
.avatarRenderer(avatarRenderer)
.informationData(informationData)
.noticeText("{ \"type\": ${event.root.getClearType()} }")
.highlighted(highlight)
.baseCallback(callback)
} }
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@ -22,7 +22,6 @@ import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.* import im.vector.matrix.android.api.session.room.model.*
import im.vector.matrix.android.api.session.room.model.call.CallInviteContent import im.vector.matrix.android.api.session.room.model.call.CallInviteContent
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.resources.StringProvider import im.vector.riotx.core.resources.StringProvider
@ -42,6 +41,9 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin
EventType.CALL_INVITE, EventType.CALL_INVITE,
EventType.CALL_HANGUP, EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> formatCallEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName()) EventType.CALL_ANSWER -> formatCallEvent(timelineEvent.root, timelineEvent.getDisambiguatedDisplayName())
EventType.MESSAGE,
EventType.REACTION,
EventType.REDACTION -> formatDebug(timelineEvent.root)
else -> { else -> {
Timber.v("Type $type not handled by this formatter") Timber.v("Type $type not handled by this formatter")
null null
@ -66,6 +68,10 @@ class NoticeEventFormatter @Inject constructor(private val stringProvider: Strin
} }
} }


private fun formatDebug(event: Event): CharSequence? {
return "{ \"type\": ${event.getClearType()} }"
}

private fun formatRoomNameEvent(event: Event, senderName: String?): CharSequence? { private fun formatRoomNameEvent(event: Event, senderName: String?): CharSequence? {
val content = event.getClearContent().toModel<RoomNameContent>() ?: return null val content = event.getClearContent().toModel<RoomNameContent>() ?: return null
return if (!TextUtils.isEmpty(content.name)) { return if (!TextUtils.isEmpty(content.name)) {

View File

@ -32,6 +32,7 @@ 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.ReadReceiptsView
import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.core.utils.DebouncedClickListener
import im.vector.riotx.core.utils.DimensionUtils.dpToPx import im.vector.riotx.core.utils.DimensionUtils.dpToPx
import im.vector.riotx.features.home.AvatarRenderer import im.vector.riotx.features.home.AvatarRenderer
@ -39,7 +40,6 @@ import im.vector.riotx.features.home.room.detail.timeline.TimelineEventControlle
import im.vector.riotx.features.reactions.widget.ReactionButton import im.vector.riotx.features.reactions.widget.ReactionButton
import im.vector.riotx.features.ui.getMessageTextColor import im.vector.riotx.features.ui.getMessageTextColor



abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() { abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {


@EpoxyAttribute @EpoxyAttribute
@ -69,6 +69,9 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
@EpoxyAttribute @EpoxyAttribute
var avatarCallback: TimelineEventController.AvatarCallback? = null var avatarCallback: TimelineEventController.AvatarCallback? = null


@EpoxyAttribute
var readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null

private val _avatarClickListener = DebouncedClickListener(View.OnClickListener { private val _avatarClickListener = DebouncedClickListener(View.OnClickListener {
avatarCallback?.onAvatarClicked(informationData) avatarCallback?.onAvatarClicked(informationData)
}) })
@ -76,6 +79,9 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
avatarCallback?.onMemberNameClicked(informationData) avatarCallback?.onMemberNameClicked(informationData)
}) })


private val _readReceiptsClickListener = DebouncedClickListener(View.OnClickListener {
readReceiptsCallback?.onReadReceiptsClicked(informationData.readReceipts)
})


var reactionClickListener: ReactionButton.ReactedListener = object : ReactionButton.ReactedListener { var reactionClickListener: ReactionButton.ReactedListener = object : ReactionButton.ReactedListener {
override fun onReacted(reactionButton: ReactionButton) { override fun onReacted(reactionButton: ReactionButton) {
@ -123,6 +129,8 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
holder.memberNameView.setOnLongClickListener(null) holder.memberNameView.setOnLongClickListener(null)
} }


holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener)

if (!shouldShowReactionAtBottom() || informationData.orderedReactionList.isNullOrEmpty()) { if (!shouldShowReactionAtBottom() || informationData.orderedReactionList.isNullOrEmpty()) {
holder.reactionWrapper?.isVisible = false holder.reactionWrapper?.isVisible = false
} else { } else {
@ -173,7 +181,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView) val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
val memberNameView by bind<TextView>(R.id.messageMemberNameView) val memberNameView by bind<TextView>(R.id.messageMemberNameView)
val timeView by bind<TextView>(R.id.messageTimeView) val timeView by bind<TextView>(R.id.messageTimeView)

val readReceiptsView by bind<ReadReceiptsView>(R.id.readReceiptsView)
var reactionWrapper: ViewGroup? = null var reactionWrapper: ViewGroup? = null
var reactionFlowHelper: Flow? = null var reactionFlowHelper: Flow? = null
} }

View File

@ -32,7 +32,8 @@ data class MessageInformationData(
/*List of reactions (emoji,count,isSelected)*/ /*List of reactions (emoji,count,isSelected)*/
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()
) : Parcelable ) : Parcelable




@ -43,3 +44,11 @@ data class ReactionInfoData(
val addedByMe: Boolean, val addedByMe: Boolean,
val synced: Boolean val synced: Boolean
) : Parcelable ) : Parcelable

@Parcelize
data class ReadReceiptData(
val userId: String,
val avatarUrl: String?,
val displayName: String?,
val timestamp: Long
) : Parcelable

View File

@ -22,6 +22,8 @@ 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.ReadReceiptsView
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


@ -44,6 +46,13 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
return@OnLongClickListener baseCallback?.onEventLongClicked(informationData, null, it) == true return@OnLongClickListener baseCallback?.onEventLongClicked(informationData, null, it) == true
} }


@EpoxyAttribute
var readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null

private val _readReceiptsClickListener = DebouncedClickListener(View.OnClickListener {
readReceiptsCallback?.onReadReceiptsClicked(informationData.readReceipts)
})

override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
holder.noticeTextView.text = noticeText holder.noticeTextView.text = noticeText
@ -55,6 +64,7 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
holder.avatarImageView holder.avatarImageView
) )
holder.view.setOnLongClickListener(longClickListener) holder.view.setOnLongClickListener(longClickListener)
holder.readReceiptsView.render(informationData.readReceipts, avatarRenderer, _readReceiptsClickListener)
} }


override fun getViewType() = STUB_ID override fun getViewType() = STUB_ID
@ -62,6 +72,7 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
class Holder : BaseHolder(STUB_ID) { class Holder : BaseHolder(STUB_ID) {
val avatarImageView by bind<ImageView>(R.id.itemNoticeAvatarView) val avatarImageView by bind<ImageView>(R.id.itemNoticeAvatarView)
val noticeTextView by bind<TextView>(R.id.itemNoticeTextView) val noticeTextView by bind<TextView>(R.id.itemNoticeTextView)
val readReceiptsView by bind<ReadReceiptsView>(R.id.readReceiptsView)
} }


companion object { companion object {

View File

@ -16,6 +16,7 @@


package im.vector.riotx.features.home.room.detail.timeline.util package im.vector.riotx.features.home.room.detail.timeline.util


import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.hasBeenEdited import im.vector.matrix.android.api.session.room.timeline.hasBeenEdited
@ -23,16 +24,18 @@ import im.vector.riotx.core.extensions.localDateTime
import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.resources.ColorProvider
import im.vector.riotx.core.utils.isSingleEmoji import im.vector.riotx.core.utils.isSingleEmoji
import im.vector.riotx.features.home.getColorFromUserId import im.vector.riotx.features.home.getColorFromUserId
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDateFormatter import im.vector.riotx.core.date.VectorDateFormatter
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.home.room.detail.timeline.item.ReactionInfoData import im.vector.riotx.features.home.room.detail.timeline.item.ReactionInfoData
import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData
import me.gujun.android.span.span import me.gujun.android.span.span
import javax.inject.Inject import javax.inject.Inject


/** /**
* This class compute if data of an event (such has avatar, display name, ...) should be displayed, depending on the previous event in the timeline * This class compute if data of an event (such has avatar, display name, ...) should be displayed, depending on the previous event in the timeline
*/ */
class MessageInformationDataFactory @Inject constructor(private val timelineDateFormatter: TimelineDateFormatter, class MessageInformationDataFactory @Inject constructor(private val session: Session,
private val dateFormatter: VectorDateFormatter,
private val colorProvider: ColorProvider) { private val colorProvider: ColorProvider) {


fun create(event: TimelineEvent, nextEvent: TimelineEvent?): MessageInformationData { fun create(event: TimelineEvent, nextEvent: TimelineEvent?): MessageInformationData {
@ -49,15 +52,14 @@ class MessageInformationDataFactory @Inject constructor(private val timelineDate
addDaySeparator addDaySeparator
|| event.senderAvatar != nextEvent?.senderAvatar || event.senderAvatar != nextEvent?.senderAvatar
|| event.getDisambiguatedDisplayName() != nextEvent?.getDisambiguatedDisplayName() || event.getDisambiguatedDisplayName() != nextEvent?.getDisambiguatedDisplayName()
|| (nextEvent?.root?.getClearType() != EventType.MESSAGE && nextEvent?.root?.getClearType() != EventType.ENCRYPTED) || (nextEvent.root.getClearType() != EventType.MESSAGE && nextEvent.root.getClearType() != EventType.ENCRYPTED)
|| isNextMessageReceivedMoreThanOneHourAgo || isNextMessageReceivedMoreThanOneHourAgo


val time = timelineDateFormatter.formatMessageHour(date) val time = dateFormatter.formatMessageHour(date)
val avatarUrl = event.senderAvatar val avatarUrl = event.senderAvatar
val memberName = event.getDisambiguatedDisplayName() val memberName = event.getDisambiguatedDisplayName()
val formattedMemberName = span(memberName) { val formattedMemberName = span(memberName) {
textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId ?: ""))
?: ""))
} }


return MessageInformationData( return MessageInformationData(
@ -74,7 +76,16 @@ class MessageInformationDataFactory @Inject constructor(private val timelineDate
ReactionInfoData(it.key, it.count, it.addedByMe, it.localEchoEvents.isEmpty()) ReactionInfoData(it.key, it.count, it.addedByMe, it.localEchoEvents.isEmpty())
}, },
hasBeenEdited = event.hasBeenEdited(), hasBeenEdited = event.hasBeenEdited(),
hasPendingEdits = event.annotations?.editSummary?.localEchos?.any() ?: false hasPendingEdits = event.annotations?.editSummary?.localEchos?.any() ?: false,
readReceipts = event.readReceipts
.asSequence()
.filter {
it.user.userId != session.myUserId
}
.map {
ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs)
}
.toList()
) )
} }
} }

View File

@ -29,13 +29,13 @@ import im.vector.riotx.core.resources.DateProvider
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.format.NoticeEventFormatter import im.vector.riotx.features.home.room.detail.timeline.format.NoticeEventFormatter
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDateFormatter import im.vector.riotx.core.date.VectorDateFormatter
import im.vector.riotx.features.home.room.detail.timeline.helper.senderName import im.vector.riotx.features.home.room.detail.timeline.helper.senderName
import me.gujun.android.span.span import me.gujun.android.span.span
import javax.inject.Inject import javax.inject.Inject


class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatter: NoticeEventFormatter, class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatter: NoticeEventFormatter,
private val timelineDateFormatter: TimelineDateFormatter, private val dateFormatter: VectorDateFormatter,
private val colorProvider: ColorProvider, private val colorProvider: ColorProvider,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val avatarRenderer: AvatarRenderer) { private val avatarRenderer: AvatarRenderer) {
@ -117,10 +117,9 @@ class RoomSummaryItemFactory @Inject constructor(private val noticeEventFormatte
} }
} }
latestEventTime = if (isSameDay) { latestEventTime = if (isSameDay) {
timelineDateFormatter.formatMessageHour(date) dateFormatter.formatMessageHour(date)
} else { } else {
//TODO: change this dateFormatter.formatMessageDay(date)
timelineDateFormatter.formatMessageDay(date)
} }
} }
return RoomSummaryItem_() return RoomSummaryItem_()

View File

@ -122,7 +122,7 @@
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" /> app:layout_constraintStart_toStartOf="parent" />


<im.vector.riotx.core.platform.NotificationAreaView <im.vector.riotx.core.ui.views.NotificationAreaView
android:id="@+id/notificationAreaView" android:id="@+id/notificationAreaView"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingEnd="8dp">

<ImageView
android:id="@+id/readReceiptAvatar"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
tools:src="@tools:sample/avatars" />

<TextView
android:id="@+id/readReceiptName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_weight="1"
android:ellipsize="end"
android:lines="1"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:textColor="?riotx_text_primary"
android:textSize="16sp"
tools:text="@sample/matrix.json/data/displayName" />

<TextView
android:id="@+id/readReceiptDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:textColor="?riotx_text_secondary"
android:textSize="12sp"
tools:text="10:44" />

</LinearLayout>

View File

@ -2,11 +2,10 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
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="44dp" android:layout_height="wrap_content"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingStart="8dp" android:paddingStart="8dp"
android:paddingLeft="8dp"
android:paddingEnd="8dp"> android:paddingEnd="8dp">


<TextView <TextView
@ -26,7 +25,8 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:layout_marginLeft="4dp" android:paddingTop="8dp"
android:paddingBottom="8dp"
android:layout_weight="1" android:layout_weight="1"
android:ellipsize="end" android:ellipsize="end"
android:lines="1" android:lines="1"

View File

@ -114,7 +114,7 @@
android:inflatedId="@+id/messageBottomInfo" android:inflatedId="@+id/messageBottomInfo"
android:layout="@layout/item_timeline_event_bottom_reactions_stub" android:layout="@layout/item_timeline_event_bottom_reactions_stub"
android:visibility="gone" android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toTopOf="@+id/readReceiptsView"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/messageStartGuideline" app:layout_constraintStart_toEndOf="@id/messageStartGuideline"
app:layout_constraintVertical_chainStyle="packed" app:layout_constraintVertical_chainStyle="packed"
@ -123,4 +123,14 @@
</ViewStub> </ViewStub>




<im.vector.riotx.core.ui.views.ReadReceiptsView
android:id="@+id/readReceiptsView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />


</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -52,5 +52,14 @@
android:layout="@layout/item_timeline_event_merged_header_stub" android:layout="@layout/item_timeline_event_merged_header_stub"
tools:ignore="MissingConstraints" /> tools:ignore="MissingConstraints" />


<im.vector.riotx.core.ui.views.ReadReceiptsView
android:id="@+id/readReceiptsView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />



</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>

<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:parentTag="android.widget.LinearLayout">

<TextView
android:id="@+id/receiptMore"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginEnd="8dp"
android:gravity="center"
android:textSize="12sp"
tools:text="999+" />

<ImageView
android:id="@+id/receiptAvatar5"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />

<ImageView
android:id="@+id/receiptAvatar4"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />

<ImageView
android:id="@+id/receiptAvatar3"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />

<ImageView
android:id="@+id/receiptAvatar2"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />

<ImageView
android:id="@+id/receiptAvatar1"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />

</merge>

View File

@ -297,6 +297,7 @@


<style name="TimelineContentStubNoInfoLayoutParams" parent="TimelineContentStubBaseParams"> <style name="TimelineContentStubNoInfoLayoutParams" parent="TimelineContentStubBaseParams">
<item name="layout_constraintTop_toTopOf">parent</item> <item name="layout_constraintTop_toTopOf">parent</item>
<item name="layout_constraintBottom_toTopOf">@id/readReceiptsView</item>
</style> </style>