First step in handling read receipts

This commit is contained in:
ganfra 2019-08-08 14:32:11 +02:00
parent 7fef063e15
commit b4ce8748cb
23 changed files with 422 additions and 126 deletions

View File

@ -16,8 +16,9 @@
package im.vector.matrix.android.api.session.room.model
import im.vector.matrix.android.api.session.user.model.User
data class ReadReceipt(
val userId: String,
val eventId: String,
val user: User,
val originServerTs: Long
)

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.toModel
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.isReply
import im.vector.matrix.android.api.util.ContentUtils.extractUsefulTextFromReply
@ -37,7 +38,8 @@ data class TimelineEvent(
val senderName: String?,
val isUniqueDisplayName: Boolean,
val senderAvatar: String?,
val annotations: EventAnnotationsSummary? = null
val annotations: EventAnnotationsSummary? = null,
val readReceipts: List<ReadReceipt> = emptyList()
) {
val metadata = HashMap<String, Any>()
@ -65,8 +67,8 @@ data class TimelineEvent(
"$name (${root.senderId})"
}
}
?: root.senderId
?: ""
?: root.senderId
?: ""
}
/**
@ -94,7 +96,7 @@ fun TimelineEvent.hasBeenEdited() = annotations?.editSummary != null
* Get last MessageContent, after a possible edition
*/
fun TimelineEvent.getLastMessageContent(): MessageContent? = annotations?.editSummary?.aggregatedContent?.toModel()
?: root.getClearContent().toModel()
?: root.getClearContent().toModel()
fun TimelineEvent.getTextEditableContent(): String? {

View File

@ -23,6 +23,8 @@ 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.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.ReadReceiptsSummaryEntity
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.find
@ -133,6 +135,23 @@ internal fun ChunkEntity.add(roomId: String,
}
val localId = TimelineEventEntity.nextId(realm)
val eventId = event.eventId ?: ""
val senderId = event.senderId ?: ""
val currentReceiptsSummary = ReadReceiptsSummaryEntity.where(realm, eventId).findFirst()
?: ReadReceiptsSummaryEntity(eventId)
// Update RR for the sender of a new message
if (direction == PaginationDirection.FORWARDS && !isUnlinked) {
ReadReceiptEntity.where(realm, roomId = roomId, userId = senderId).findFirst()?.also {
val previousEventId = it.eventId
val previousReceiptsSummary = ReadReceiptsSummaryEntity.where(realm, eventId = previousEventId).findFirst()
it.eventId = eventId
previousReceiptsSummary?.readReceipts?.remove(it)
currentReceiptsSummary.readReceipts.add(it)
}
}
val eventEntity = TimelineEventEntity(localId).also {
it.root = event.toEntity(roomId).apply {
this.stateIndex = currentStateIndex
@ -140,9 +159,10 @@ internal fun ChunkEntity.add(roomId: String,
this.displayIndex = currentDisplayIndex
this.sendState = SendState.SYNCED
}
it.eventId = event.eventId ?: ""
it.eventId = eventId
it.roomId = roomId
it.annotations = EventAnnotationsSummaryEntity.where(realm, it.eventId).findFirst()
it.annotations = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
it.readReceipts = currentReceiptsSummary
}
val position = if (direction == PaginationDirection.FORWARDS) 0 else this.timelineEvents.size
timelineEvents.add(position, eventEntity)
@ -150,14 +170,14 @@ internal fun ChunkEntity.add(roomId: String,
internal fun ChunkEntity.lastDisplayIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) {
PaginationDirection.FORWARDS -> forwardsDisplayIndex
PaginationDirection.BACKWARDS -> backwardsDisplayIndex
} ?: defaultValue
PaginationDirection.FORWARDS -> forwardsDisplayIndex
PaginationDirection.BACKWARDS -> backwardsDisplayIndex
} ?: defaultValue
}
internal fun ChunkEntity.lastStateIndex(direction: PaginationDirection, defaultValue: Int = 0): Int {
return when (direction) {
PaginationDirection.FORWARDS -> forwardsStateIndex
PaginationDirection.BACKWARDS -> backwardsStateIndex
} ?: defaultValue
PaginationDirection.FORWARDS -> forwardsStateIndex
PaginationDirection.BACKWARDS -> backwardsStateIndex
} ?: defaultValue
}

View File

@ -0,0 +1,40 @@
/*
* 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> {
return Realm.getInstance(realmConfiguration).use { realm ->
readReceiptsSummaryEntity.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
internal class RoomSummaryMapper @Inject constructor(
val cryptoService: CryptoService
val cryptoService: CryptoService,
val timelineEventMapper: TimelineEventMapper
) {
fun map(roomSummaryEntity: RoomSummaryEntity): RoomSummary {
@ -34,7 +35,9 @@ internal class RoomSummaryMapper @Inject constructor(
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) {
//TODO use a global event decryptor? attache to session and that listen to new sessionId?
//for now decrypt sync

View File

@ -17,29 +17,30 @@
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.room.timeline.TimelineEvent
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 {
return TimelineEvent(
root = timelineEventEntity.root?.asDomain()
?: Event("", timelineEventEntity.eventId),
?: Event("", timelineEventEntity.eventId),
annotations = timelineEventEntity.annotations?.asDomain(),
localId = timelineEventEntity.localId,
displayIndex = timelineEventEntity.root?.displayIndex ?: 0,
senderName = timelineEventEntity.senderName,
isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName,
senderAvatar = timelineEventEntity.senderAvatar
senderAvatar = timelineEventEntity.senderAvatar,
readReceipts = timelineEventEntity.readReceipts?.let {
readReceiptsSummaryMapper.map(it)
} ?: emptyList()
)
}
}
internal fun TimelineEventEntity.asDomain(): TimelineEvent {
return TimelineEventMapper.map(this)
}

View File

@ -17,13 +17,18 @@
package im.vector.matrix.android.internal.database.model
import io.realm.RealmObject
import io.realm.RealmResults
import io.realm.annotations.LinkingObjects
import io.realm.annotations.PrimaryKey
internal open class ReadReceiptEntity(@PrimaryKey var primaryKey: String = "",
var userId: String = "",
var eventId: String = "",
var roomId: String = "",
var originServerTs: Double = 0.0
var eventId: String = "",
var roomId: String = "",
var userId: String = "",
var originServerTs: Double = 0.0
) : RealmObject() {
companion object
@LinkingObjects("readReceipts")
val summary: RealmResults<ReadReceiptsSummaryEntity>? = null
}

View File

@ -0,0 +1,31 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.database.model
import io.realm.RealmList
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
internal open class ReadReceiptsSummaryEntity(
@PrimaryKey
var eventId: String = "",
var readReceipts: RealmList<ReadReceiptEntity> = RealmList()
) : RealmObject() {
companion object
}

View File

@ -22,26 +22,27 @@ import io.realm.annotations.RealmModule
* Realm module for Session
*/
@RealmModule(library = true,
classes = [
ChunkEntity::class,
EventEntity::class,
TimelineEventEntity::class,
FilterEntity::class,
GroupEntity::class,
GroupSummaryEntity::class,
ReadReceiptEntity::class,
RoomEntity::class,
RoomSummaryEntity::class,
RoomTagEntity::class,
SyncEntity::class,
UserEntity::class,
EventAnnotationsSummaryEntity::class,
ReactionAggregatedSummaryEntity::class,
EditAggregatedSummaryEntity::class,
PushRulesEntity::class,
PushRuleEntity::class,
PushConditionEntity::class,
PusherEntity::class,
PusherDataEntity::class
])
classes = [
ChunkEntity::class,
EventEntity::class,
TimelineEventEntity::class,
FilterEntity::class,
GroupEntity::class,
GroupSummaryEntity::class,
ReadReceiptEntity::class,
RoomEntity::class,
RoomSummaryEntity::class,
RoomTagEntity::class,
SyncEntity::class,
UserEntity::class,
EventAnnotationsSummaryEntity::class,
ReactionAggregatedSummaryEntity::class,
EditAggregatedSummaryEntity::class,
PushRulesEntity::class,
PushRuleEntity::class,
PushConditionEntity::class,
PusherEntity::class,
PusherDataEntity::class,
ReadReceiptsSummaryEntity::class
])
internal class SessionRealmModule

View File

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

View File

@ -0,0 +1,28 @@
/*
* 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)
}

View File

@ -22,6 +22,7 @@ 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.room.Room
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.LoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.membership.joining.InviteTask
@ -47,6 +48,7 @@ internal class RoomFactory @Inject constructor(private val context: Context,
private val monarchy: Monarchy,
private val eventFactory: LocalEchoEventFactory,
private val roomSummaryMapper: RoomSummaryMapper,
private val timelineEventMapper: TimelineEventMapper,
private val taskExecutor: TaskExecutor,
private val loadRoomMembersTask: LoadRoomMembersTask,
private val inviteTask: InviteTask,
@ -61,13 +63,13 @@ internal class RoomFactory @Inject constructor(private val context: Context,
private val leaveRoomTask: LeaveRoomTask) {
fun create(roomId: String): Room {
val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, cryptoService, paginationTask)
val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, cryptoService, paginationTask, timelineEventMapper)
val sendService = DefaultSendService(context, credentials, roomId, eventFactory, cryptoService, monarchy)
val stateService = DefaultStateService(roomId, monarchy.realmConfiguration, taskExecutor, sendStateTask)
val roomMembersService = DefaultMembershipService(roomId, monarchy, taskExecutor, loadRoomMembersTask, inviteTask, joinRoomTask, leaveRoomTask)
val readService = DefaultReadService(roomId, monarchy, taskExecutor, setReadMarkersTask, credentials)
val relationService = DefaultRelationService(context,
credentials, roomId, eventFactory, cryptoService, findReactionEventForUndoTask, fetchEditHistoryTask, monarchy, taskExecutor)
credentials, roomId, eventFactory, cryptoService, findReactionEventForUndoTask, fetchEditHistoryTask, monarchy, taskExecutor)
return DefaultRoom(
roomId,

View File

@ -23,6 +23,7 @@ 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.TimelineEvent
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.model.*
import im.vector.matrix.android.internal.database.model.EventEntity
@ -53,7 +54,8 @@ internal class DefaultTimeline(
private val taskExecutor: TaskExecutor,
private val contextOfEventTask: GetContextOfEventTask,
private val paginationTask: PaginationTask,
cryptoService: CryptoService,
private val cryptoService: CryptoService,
private val timelineEventMapper: TimelineEventMapper,
private val allowedTypes: List<String>?
) : Timeline {
@ -132,7 +134,7 @@ internal class DefaultTimeline(
builtEventsIdMap[eventId]?.let { builtIndex ->
//Update the relation of existing event
builtEvents[builtIndex]?.let { te ->
builtEvents[builtIndex] = eventEntity.asDomain()
builtEvents[builtIndex] = timelineEventMapper.map(eventEntity)
hasChanged = true
}
}
@ -331,7 +333,7 @@ internal class DefaultTimeline(
roomEntity?.sendingTimelineEvents
?.filter { allowedTypes?.contains(it.root?.type) ?: false }
?.forEach {
sendingEvents.add(it.asDomain())
sendingEvents.add(timelineEventMapper.map(it))
}
}
return sendingEvents
@ -463,7 +465,7 @@ internal class DefaultTimeline(
nextDisplayIndex = offsetIndex + 1
}
offsetResults.forEach { eventEntity ->
val timelineEvent = eventEntity.asDomain()
val timelineEvent = timelineEventMapper.map(eventEntity)
if (timelineEvent.isEncrypted()
&& timelineEvent.root.mxDecryptionResult == null) {

View File

@ -24,6 +24,7 @@ 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.TimelineService
import im.vector.matrix.android.internal.database.RealmLiveData
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.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.query.where
@ -36,7 +37,8 @@ internal class DefaultTimelineService @Inject constructor(private val roomId: St
private val taskExecutor: TaskExecutor,
private val contextOfEventTask: GetContextOfEventTask,
private val cryptoService: CryptoService,
private val paginationTask: PaginationTask
private val paginationTask: PaginationTask,
private val timelineEventMapper: TimelineEventMapper
) : TimelineService {
override fun createTimeline(eventId: String?, allowedTypes: List<String>?): Timeline {
@ -47,7 +49,9 @@ internal class DefaultTimelineService @Inject constructor(private val roomId: St
contextOfEventTask,
paginationTask,
cryptoService,
allowedTypes)
timelineEventMapper,
allowedTypes
)
}
override fun getTimeLineEvent(eventId: String): TimelineEvent? {
@ -55,7 +59,7 @@ internal class DefaultTimelineService @Inject constructor(private val roomId: St
.fetchCopyMap({
TimelineEventEntity.where(it, eventId = eventId).findFirst()
}, { entity, realm ->
entity.asDomain()
timelineEventMapper.map(entity)
})
}
@ -63,8 +67,8 @@ internal class DefaultTimelineService @Inject constructor(private val roomId: St
val liveData = RealmLiveData(monarchy.realmConfiguration) {
TimelineEventEntity.where(it, eventId = eventId)
}
return Transformations.map(liveData) {
it.firstOrNull()?.asDomain()
return Transformations.map(liveData) { events ->
events.firstOrNull()?.let { timelineEventMapper.map(it) }
}
}

View File

@ -17,6 +17,8 @@
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.ReadReceiptsSummaryEntity
import im.vector.matrix.android.internal.database.query.where
import io.realm.Realm
import timber.log.Timber
import javax.inject.Inject
@ -36,27 +38,35 @@ internal class ReadReceiptHandler @Inject constructor() {
return
}
try {
val readReceipts = mapContentToReadReceiptEntities(roomId, content)
realm.insertOrUpdate(readReceipts)
handleReadReceiptContent(realm, roomId, content)
} catch (exception: Exception) {
Timber.e("Fail to handle read receipt for room $roomId")
}
}
private fun mapContentToReadReceiptEntities(roomId: String, content: ReadReceiptContent): List<ReadReceiptEntity> {
return content
.flatMap { (eventId, receiptDict) ->
receiptDict
.filterKeys { it == "m.read" }
.flatMap { (_, userIdsDict) ->
userIdsDict.map { (userId, paramsDict) ->
val ts = paramsDict.filterKeys { it == "ts" }
.values
.firstOrNull() ?: 0.0
val primaryKey = roomId + userId
ReadReceiptEntity(primaryKey, userId, eventId, roomId, ts)
}
}
private fun handleReadReceiptContent(realm: Realm, roomId: String, content: ReadReceiptContent) {
for ((eventId, receiptDict) in content) {
val userIdsDict = receiptDict["m.read"] ?: continue
val readReceiptsSummary = ReadReceiptsSummaryEntity.where(realm, eventId).findFirst()
?: realm.createObject(ReadReceiptsSummaryEntity::class.java, eventId)
for ((userId, paramsDict) in userIdsDict) {
val ts = paramsDict["ts"] ?: 0.0
val primaryKey = "${roomId}_$userId"
val receiptEntity = ReadReceiptEntity.where(realm, roomId, userId).findFirst()
?: realm.createObject(ReadReceiptEntity::class.java, primaryKey)
ReadReceiptsSummaryEntity.where(realm, receiptEntity.eventId).findFirst()?.also {
it.readReceipts.remove(receiptEntity)
}
receiptEntity.apply {
this.eventId = eventId
this.roomId = roomId
this.userId = userId
this.originServerTs = ts
}
readReceiptsSummary.readReceipts.add(receiptEntity)
}
}
}
}

View File

@ -117,7 +117,7 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
Timber.v("Handle join sync for room $roomId")
val roomEntity = RoomEntity.where(realm, roomId).findFirst()
?: realm.createObject(roomId)
?: realm.createObject(roomId)
if (roomEntity.membership == Membership.INVITE) {
roomEntity.chunks.deleteAllFromRealm()
@ -127,7 +127,7 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
// State event
if (roomSync.state != null && roomSync.state.events.isNotEmpty()) {
val minStateIndex = roomEntity.untimelinedStateEvents.where().min(EventEntityFields.STATE_INDEX)?.toInt()
?: Int.MIN_VALUE
?: Int.MIN_VALUE
val untimelinedStateIndex = minStateIndex + 1
roomSync.state.events.forEach { event ->
roomEntity.addStateEvent(event, filterDuplicates = true, stateIndex = untimelinedStateIndex)
@ -167,7 +167,7 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
InvitedRoomSync): RoomEntity {
Timber.v("Handle invited sync for room $roomId")
val roomEntity = RoomEntity.where(realm, roomId).findFirst()
?: realm.createObject(roomId)
?: realm.createObject(roomId)
roomEntity.membership = Membership.INVITE
if (roomSync.inviteState != null && roomSync.inviteState.events.isNotEmpty()) {
val chunkEntity = handleTimelineEvents(realm, roomEntity, roomSync.inviteState.events)
@ -181,7 +181,7 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
roomId: String,
roomSync: RoomSync): RoomEntity {
val roomEntity = RoomEntity.where(realm, roomId).findFirst()
?: realm.createObject(roomId)
?: realm.createObject(roomId)
roomEntity.membership = Membership.LEAVE
roomEntity.chunks.deleteAllFromRealm()
@ -233,17 +233,20 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
}
@Suppress("UNCHECKED_CAST")
private fun handleEphemeral(realm: Realm,
roomId: String,
ephemeral: RoomSyncEphemeral) {
ephemeral.events
.filter { it.getClearType() == EventType.RECEIPT }
.map { it.content.toModel<ReadReceiptContent>() }
.forEach { readReceiptHandler.handle(realm, roomId, it) }
for (event in ephemeral.events) {
if (event.type != EventType.RECEIPT) continue
val readReceiptContent = event.content as? ReadReceiptContent ?: continue
readReceiptHandler.handle(realm, roomId, readReceiptContent)
}
}
private fun handleRoomAccountDataEvents(realm: Realm, roomId: String, accountData: RoomSyncAccountData) {
accountData.events
.asSequence()
.filter { it.getClearType() == EventType.TAG }
.map { it.content.toModel<RoomTagContent>() }
.forEach { roomTagHandler.handle(realm, roomId, it) }

View File

@ -161,7 +161,6 @@ abstract class VectorBaseActivity : BaseMvRxActivity(), HasScreenInjector {
override fun onDestroy() {
super.onDestroy()
unBinder?.unbind()
unBinder = null

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.session.events.model.RelationType
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.send.SendState
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
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.getLastMessageContent
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.TimelineMediaSizeProvider
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.html.EventHtmlRenderer
import im.vector.riotx.features.media.ImageContentRenderer
@ -84,11 +103,11 @@ class MessageItemFactory @Inject constructor(
val messageContent: MessageContent =
event.getLastMessageContent()
?: //Malformed content, we should echo something on screen
return DefaultItem_().text(stringProvider.getString(R.string.malformed_message))
?: //Malformed content, we should echo something on screen
return DefaultItem_().text(stringProvider.getString(R.string.malformed_message))
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
if (userPreferencesProvider.shouldShowHiddenEvents()) {
@ -116,15 +135,13 @@ class MessageItemFactory @Inject constructor(
// val ev = all.toModel<Event>()
return when (messageContent) {
is MessageEmoteContent -> buildEmoteMessageItem(messageContent,
informationData,
highlight,
callback)
is MessageTextContent -> buildTextMessageItem(event.root.sendState,
messageContent,
informationData,
highlight,
callback
)
informationData,
highlight,
callback)
is MessageTextContent -> buildTextMessageItem(messageContent,
informationData,
highlight,
callback)
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, highlight, callback)
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback)
@ -158,7 +175,7 @@ class MessageItemFactory @Inject constructor(
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
?: false
}
}
@ -182,7 +199,7 @@ class MessageItemFactory @Inject constructor(
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
?: false
}
.clickListener(
DebouncedClickListener(View.OnClickListener { _ ->
@ -190,7 +207,7 @@ class MessageItemFactory @Inject constructor(
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
?: false
}
}
@ -240,7 +257,7 @@ class MessageItemFactory @Inject constructor(
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
?: false
}
}
@ -253,7 +270,7 @@ class MessageItemFactory @Inject constructor(
val thumbnailData = ImageContentRenderer.Data(
filename = messageContent.body,
url = messageContent.videoInfo?.thumbnailFile?.url
?: messageContent.videoInfo?.thumbnailUrl,
?: messageContent.videoInfo?.thumbnailUrl,
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = messageContent.videoInfo?.height,
maxHeight = maxHeight,
@ -288,12 +305,11 @@ class MessageItemFactory @Inject constructor(
.clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view) }
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
?: false
}
}
private fun buildTextMessageItem(sendState: SendState,
messageContent: MessageTextContent,
private fun buildTextMessageItem(messageContent: MessageTextContent,
informationData: MessageInformationData,
highlight: Boolean,
callback: TimelineEventController.Callback?): MessageTextItem? {
@ -328,7 +344,7 @@ class MessageItemFactory @Inject constructor(
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
?: false
}
}
@ -358,9 +374,9 @@ class MessageItemFactory @Inject constructor(
//nop
}
},
editStart,
editEnd,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
editStart,
editEnd,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
return spannable
}
@ -397,7 +413,7 @@ class MessageItemFactory @Inject constructor(
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
?: false
}
}
@ -433,7 +449,7 @@ class MessageItemFactory @Inject constructor(
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
?: false
}
}
@ -452,7 +468,7 @@ class MessageItemFactory @Inject constructor(
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, null, view)
?: false
?: false
}
}

View File

@ -16,6 +16,7 @@
package im.vector.riotx.features.home.room.detail.timeline.item
import android.annotation.SuppressLint
import android.graphics.Typeface
import android.os.Build
import android.view.View
@ -39,6 +40,7 @@ import im.vector.riotx.features.home.room.detail.timeline.TimelineEventControlle
import im.vector.riotx.features.reactions.widget.ReactionButton
import im.vector.riotx.features.ui.getMessageTextColor
private const val MAX_RECEIPT_DISPLAYED = 5
abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
@ -123,6 +125,29 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
holder.memberNameView.setOnLongClickListener(null)
}
if (informationData.readReceipts.isNotEmpty()) {
holder.readReceiptsView.isVisible = true
for (index in 0 until MAX_RECEIPT_DISPLAYED) {
val receiptData = informationData.readReceipts.getOrNull(index)
if (receiptData == null) {
holder.receiptAvatars[index].isVisible = false
} else {
holder.receiptAvatars[index].isVisible = true
avatarRenderer.render(receiptData.avatarUrl, receiptData.userId, receiptData.displayName, holder.receiptAvatars[index])
}
}
if (informationData.readReceipts.size > MAX_RECEIPT_DISPLAYED) {
holder.receiptMoreView.isVisible = true
holder.receiptMoreView.text = holder.view.context.getString(
R.string.x_plus, informationData.readReceipts.size - MAX_RECEIPT_DISPLAYED
)
} else {
holder.receiptMoreView.isVisible = false
}
} else {
holder.readReceiptsView.isVisible = false
}
if (!shouldShowReactionAtBottom() || informationData.orderedReactionList.isNullOrEmpty()) {
holder.reactionWrapper?.isVisible = false
} else {
@ -173,6 +198,16 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
val avatarImageView by bind<ImageView>(R.id.messageAvatarImageView)
val memberNameView by bind<TextView>(R.id.messageMemberNameView)
val timeView by bind<TextView>(R.id.messageTimeView)
val readReceiptsView by bind<ViewGroup>(R.id.readReceiptsView)
val receiptAvatar1 by bind<ImageView>(R.id.message_avatar_receipt_1)
val receiptAvatar2 by bind<ImageView>(R.id.message_avatar_receipt_2)
val receiptAvatar3 by bind<ImageView>(R.id.message_avatar_receipt_3)
val receiptAvatar4 by bind<ImageView>(R.id.message_avatar_receipt_4)
val receiptAvatar5 by bind<ImageView>(R.id.message_avatar_receipt_5)
val receiptMoreView by bind<TextView>(R.id.message_more_than_expected)
val receiptAvatars: List<ImageView> by lazy {
listOf(receiptAvatar1, receiptAvatar2, receiptAvatar3, receiptAvatar4, receiptAvatar5)
}
var reactionWrapper: ViewGroup? = null
var reactionFlowHelper: Flow? = null

View File

@ -17,6 +17,7 @@
package im.vector.riotx.features.home.room.detail.timeline.item
import android.os.Parcelable
import im.vector.matrix.android.api.session.room.model.ReadReceipt
import im.vector.matrix.android.api.session.room.send.SendState
import kotlinx.android.parcel.Parcelize
@ -32,7 +33,8 @@ data class MessageInformationData(
/*List of reactions (emoji,count,isSelected)*/
val orderedReactionList: List<ReactionInfoData>? = null,
val hasBeenEdited: Boolean = false,
val hasPendingEdits: Boolean = false
val hasPendingEdits: Boolean = false,
val readReceipts: List<ReadReceiptData> = emptyList()
) : Parcelable
@ -43,3 +45,11 @@ data class ReactionInfoData(
val addedByMe: Boolean,
val synced: Boolean
) : Parcelable
@Parcelize
data class ReadReceiptData(
val userId: String,
val avatarUrl: String?,
val displayName: String?,
val timestamp: Long
) : Parcelable

View File

@ -16,6 +16,7 @@
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.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.hasBeenEdited
@ -26,13 +27,15 @@ import im.vector.riotx.features.home.getColorFromUserId
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDateFormatter
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.ReadReceiptData
import me.gujun.android.span.span
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
*/
class MessageInformationDataFactory @Inject constructor(private val timelineDateFormatter: TimelineDateFormatter,
class MessageInformationDataFactory @Inject constructor(private val session: Session,
private val timelineDateFormatter: TimelineDateFormatter,
private val colorProvider: ColorProvider) {
fun create(event: TimelineEvent, nextEvent: TimelineEvent?): MessageInformationData {
@ -43,21 +46,21 @@ class MessageInformationDataFactory @Inject constructor(private val timelineDate
val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60))
?: false
?: false
val showInformation =
addDaySeparator
|| event.senderAvatar != nextEvent?.senderAvatar
|| event.getDisambiguatedDisplayName() != nextEvent?.getDisambiguatedDisplayName()
|| (nextEvent?.root?.getClearType() != EventType.MESSAGE && nextEvent?.root?.getClearType() != EventType.ENCRYPTED)
|| isNextMessageReceivedMoreThanOneHourAgo
|| event.senderAvatar != nextEvent?.senderAvatar
|| event.getDisambiguatedDisplayName() != nextEvent?.getDisambiguatedDisplayName()
|| (nextEvent?.root?.getClearType() != EventType.MESSAGE && nextEvent?.root?.getClearType() != EventType.ENCRYPTED)
|| isNextMessageReceivedMoreThanOneHourAgo
val time = timelineDateFormatter.formatMessageHour(date)
val avatarUrl = event.senderAvatar
val memberName = event.getDisambiguatedDisplayName()
val formattedMemberName = span(memberName) {
textColor = colorProvider.getColor(getColorFromUserId(event.root.senderId
?: ""))
?: ""))
}
return MessageInformationData(
@ -74,7 +77,14 @@ class MessageInformationDataFactory @Inject constructor(private val timelineDate
ReactionInfoData(it.key, it.count, it.addedByMe, it.localEchoEvents.isEmpty())
},
hasBeenEdited = event.hasBeenEdited(),
hasPendingEdits = event.annotations?.editSummary?.localEchos?.any() ?: false
hasPendingEdits = event.annotations?.editSummary?.localEchos?.any() ?: false,
readReceipts = event.readReceipts
.filter {
it.user.userId != session.myUserId
}
.map {
ReadReceiptData(it.user.userId, it.user.avatarUrl, it.user.displayName, it.originServerTs)
}
)
}
}

View File

@ -114,7 +114,7 @@
android:inflatedId="@+id/messageBottomInfo"
android:layout="@layout/item_timeline_event_bottom_reactions_stub"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@+id/readReceiptsView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/messageStartGuideline"
app:layout_constraintVertical_chainStyle="packed"
@ -123,4 +123,65 @@
</ViewStub>
<LinearLayout
android:id="@+id/readReceiptsView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:id="@+id/message_more_than_expected"
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/message_avatar_receipt_5"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/message_avatar_receipt_4"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/message_avatar_receipt_3"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/message_avatar_receipt_2"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />
<ImageView
android:id="@+id/message_avatar_receipt_1"
android:layout_width="16dp"
android:layout_height="16dp"
android:adjustViewBounds="true"
android:scaleType="centerCrop"
tools:src="@tools:sample/avatars" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/readReceiptsView"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</RelativeLayout>