Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/6996.sdk
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added support for read receipts in threads. Now user in a room can have multiple read receipts (one per thread + one in main thread + one without threadId)
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ class FlowRoom(private val room: Room) {
return room.readService().getReadMarkerLive().asFlow()
}

fun liveReadReceipt(): Flow<Optional<String>> {
return room.readService().getMyReadReceiptLive().asFlow()
fun liveReadReceipt(threadId: String?): Flow<Optional<String>> {
return room.readService().getMyReadReceiptLive(threadId).asFlow()
}

fun liveEventReadReceipts(eventId: String): Flow<List<ReadReceipt>> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ package org.matrix.android.sdk.api.session.room.model

data class ReadReceipt(
val roomMember: RoomMemberSummary,
val originServerTs: Long
val originServerTs: Long,
val threadId: String?
)
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@ interface ReadService {
/**
* Force the read marker to be set on the latest event.
*/
suspend fun markAsRead(params: MarkAsReadParams = MarkAsReadParams.BOTH)
suspend fun markAsRead(params: MarkAsReadParams = MarkAsReadParams.BOTH, mainTimeLineOnly: Boolean = true)

/**
* Set the read receipt on the event with provided eventId.
*/
suspend fun setReadReceipt(eventId: String)
suspend fun setReadReceipt(eventId: String, threadId: String)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add Kdoc for parameters, especially tell about the possible value THREAD_ID_MAIN for threadId.


/**
* Set the read marker on the event with provided eventId.
Expand All @@ -59,10 +59,10 @@ interface ReadService {
/**
* Returns a live read receipt id for the room.
*/
fun getMyReadReceiptLive(): LiveData<Optional<String>>
fun getMyReadReceiptLive(threadId: String?): LiveData<Optional<String>>

/**
* Get the eventId where the read receipt for the provided user is.
* Get the eventId from the main timeline where the read receipt for the provided user is.
* @param userId the id of the user to look for
*
* @return the eventId where the read receipt for the provided user is attached, or null if not found
Expand All @@ -74,4 +74,8 @@ interface ReadService {
* @param eventId the event
*/
fun getEventReadReceiptsLive(eventId: String): LiveData<List<ReadReceipt>>

companion object {
const val THREAD_ID_MAIN = "main"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ private fun handleReadReceipts(realm: Realm, roomId: String, eventEntity: EventE
val originServerTs = eventEntity.originServerTs
if (originServerTs != null) {
val timestampOfEvent = originServerTs.toDouble()
val readReceiptOfSender = ReadReceiptEntity.getOrCreate(realm, roomId = roomId, userId = senderId)
val readReceiptOfSender = ReadReceiptEntity.getOrCreate(realm, roomId = roomId, userId = senderId, threadId = eventEntity.rootThreadEventId)
// If the synced RR is older, update
if (timestampOfEvent > readReceiptOfSender.originServerTs) {
val previousReceiptsSummary = ReadReceiptsSummaryEntity.where(realm, eventId = readReceiptOfSender.eventId).findFirst()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ internal fun Map<String, EventEntity>.updateThreadSummaryIfNeeded(
inThreadMessages = inThreadMessages,
latestMessageTimelineEventEntity = latestEventInThread
)
}
}

if (shouldUpdateNotifications) {
updateNotificationsNew(roomId, realm, currentUserId)
if (shouldUpdateNotifications) {
updateThreadNotifications(roomId, realm, currentUserId, rootThreadEventId)
}
}
}
}

Expand Down Expand Up @@ -273,8 +273,8 @@ internal fun TimelineEventEntity.Companion.isUserMentionedInThread(realm: Realm,
/**
* Find the read receipt for the current user.
*/
internal fun findMyReadReceipt(realm: Realm, roomId: String, userId: String): String? =
ReadReceiptEntity.where(realm, roomId = roomId, userId = userId)
internal fun findMyReadReceipt(realm: Realm, roomId: String, userId: String, threadId: String?): String? =
ReadReceiptEntity.where(realm, roomId = roomId, userId = userId, threadId = threadId)
.findFirst()
?.eventId

Expand All @@ -293,28 +293,29 @@ internal fun isUserMentioned(currentUserId: String, timelineEventEntity: Timelin
* Important: It will work only with the latest chunk, while read marker will be changed
* immediately so we should not display wrong notifications
*/
internal fun updateNotificationsNew(roomId: String, realm: Realm, currentUserId: String) {
val readReceipt = findMyReadReceipt(realm, roomId, currentUserId) ?: return
internal fun updateThreadNotifications(roomId: String, realm: Realm, currentUserId: String, rootThreadEventId: String) {
val readReceipt = findMyReadReceipt(realm, roomId, currentUserId, threadId = rootThreadEventId) ?: return

val readReceiptChunk = ChunkEntity
.findIncludingEvent(realm, readReceipt) ?: return

val readReceiptChunkTimelineEvents = readReceiptChunk
val readReceiptChunkThreadEvents = readReceiptChunk
.timelineEvents
.where()
.equalTo(TimelineEventEntityFields.ROOM_ID, roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING)
.findAll() ?: return

val readReceiptChunkPosition = readReceiptChunkTimelineEvents.indexOfFirst { it.eventId == readReceipt }
val readReceiptChunkPosition = readReceiptChunkThreadEvents.indexOfFirst { it.eventId == readReceipt }

if (readReceiptChunkPosition == -1) return

if (readReceiptChunkPosition < readReceiptChunkTimelineEvents.lastIndex) {
if (readReceiptChunkPosition < readReceiptChunkThreadEvents.lastIndex) {
// If the read receipt is found inside the chunk

val threadEventsAfterReadReceipt = readReceiptChunkTimelineEvents
.slice(readReceiptChunkPosition..readReceiptChunkTimelineEvents.lastIndex)
val threadEventsAfterReadReceipt = readReceiptChunkThreadEvents
.slice(readReceiptChunkPosition..readReceiptChunkThreadEvents.lastIndex)
.filter { it.root?.isThread() == true }

// In order for the below code to work for old events, we should save the previous read receipt
Expand Down Expand Up @@ -343,26 +344,21 @@ internal fun updateNotificationsNew(roomId: String, realm: Realm, currentUserId:
it.root?.rootThreadEventId
}

// Find the root events in the new thread events
val rootThreads = threadEventsAfterReadReceipt.distinctBy { it.root?.rootThreadEventId }.mapNotNull { it.root?.rootThreadEventId }

// Update root thread events only if the user have participated in
rootThreads.forEach { eventId ->
val isUserParticipating = TimelineEventEntity.isUserParticipatingInThread(
realm = realm,
roomId = roomId,
rootThreadEventId = eventId,
senderId = currentUserId
)
val rootThreadEventEntity = EventEntity.where(realm, eventId).findFirst()

if (isUserParticipating) {
rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_MESSAGE
}
// Update root thread event only if the user have participated in
val isUserParticipating = TimelineEventEntity.isUserParticipatingInThread(
realm = realm,
roomId = roomId,
rootThreadEventId = rootThreadEventId,
senderId = currentUserId
)
val rootThreadEventEntity = EventEntity.where(realm, rootThreadEventId).findFirst()

if (isUserParticipating) {
rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_MESSAGE
}

if (userMentionsList.contains(eventId)) {
rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE
}
if (userMentionsList.contains(rootThreadEventId)) {
rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ internal class ReadReceiptsSummaryMapper @Inject constructor(
.mapNotNull {
val roomMember = RoomMemberSummaryEntity.where(realm, roomId = it.roomId, userId = it.userId).findFirst()
?: return@mapNotNull null
ReadReceipt(roomMember.asDomain(), it.originServerTs.toLong())
ReadReceipt(roomMember.asDomain(), it.originServerTs.toLong(), it.threadId)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal open class ReadReceiptEntity(
var eventId: String = "",
var roomId: String = "",
var userId: String = "",
var threadId: String? = null,
var originServerTs: Double = 0.0
) : RealmObject() {
companion object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import io.realm.RealmObject
import io.realm.RealmResults
import io.realm.annotations.Index
import io.realm.annotations.LinkingObjects
import org.matrix.android.sdk.api.session.room.read.ReadService
import org.matrix.android.sdk.internal.extensions.assertIsManaged

internal open class TimelineEventEntity(
Expand Down Expand Up @@ -52,3 +53,7 @@ internal fun TimelineEventEntity.deleteOnCascade(canDeleteRoot: Boolean) {
}
deleteFromRealm()
}

internal fun TimelineEventEntity.getThreadId(): String {
return root?.rootThreadEventId ?: ReadService.THREAD_ID_MAIN
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,20 @@ package org.matrix.android.sdk.internal.database.query
import io.realm.Realm
import io.realm.RealmConfiguration
import org.matrix.android.sdk.api.session.events.model.LocalEcho
import org.matrix.android.sdk.api.session.room.read.ReadService
import org.matrix.android.sdk.internal.database.helper.isMoreRecentThan
import org.matrix.android.sdk.internal.database.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.ReadMarkerEntity
import org.matrix.android.sdk.internal.database.model.ReadReceiptEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.getThreadId

internal fun isEventRead(
realmConfiguration: RealmConfiguration,
userId: String?,
roomId: String?,
eventId: String?
eventId: String?,
shouldCheckIfReadInEventsThread: Boolean
): Boolean {
if (userId.isNullOrBlank() || roomId.isNullOrBlank() || eventId.isNullOrBlank()) {
return false
Expand All @@ -45,7 +48,8 @@ internal fun isEventRead(
eventToCheck.root?.sender == userId -> true
// If new event exists and the latest event is from ourselves we can infer the event is read
latestEventIsFromSelf(realm, roomId, userId) -> true
eventToCheck.isBeforeLatestReadReceipt(realm, roomId, userId) -> true
eventToCheck.isBeforeLatestReadReceipt(realm, roomId, userId, null) -> true
(shouldCheckIfReadInEventsThread && eventToCheck.isBeforeLatestReadReceipt(realm, roomId, userId, eventToCheck.getThreadId())) -> true
else -> false
}
}
Expand All @@ -54,27 +58,33 @@ internal fun isEventRead(
private fun latestEventIsFromSelf(realm: Realm, roomId: String, userId: String) = TimelineEventEntity.latestEvent(realm, roomId, true)
?.root?.sender == userId

private fun TimelineEventEntity.isBeforeLatestReadReceipt(realm: Realm, roomId: String, userId: String): Boolean {
return ReadReceiptEntity.where(realm, roomId, userId).findFirst()?.let { readReceipt ->
private fun TimelineEventEntity.isBeforeLatestReadReceipt(realm: Realm, roomId: String, userId: String, threadId: String?): Boolean {
val isMoreRecent = ReadReceiptEntity.where(realm, roomId, userId, threadId).findFirst()?.let { readReceipt ->
val readReceiptEvent = TimelineEventEntity.where(realm, roomId, readReceipt.eventId).findFirst()
readReceiptEvent?.isMoreRecentThan(this)
} ?: false
return isMoreRecent
}

/**
* Missing events can be caused by the latest timeline chunk no longer contain an older event or
* by fast lane eagerly displaying events before the database has finished updating.
*/
private fun hasReadMissingEvent(realm: Realm, latestChunkEntity: ChunkEntity, roomId: String, userId: String, eventId: String): Boolean {
return realm.doesEventExistInChunkHistory(eventId) && realm.hasReadReceiptInLatestChunk(latestChunkEntity, roomId, userId)
private fun hasReadMissingEvent(realm: Realm,
latestChunkEntity: ChunkEntity,
roomId: String,
userId: String,
eventId: String,
threadId: String? = ReadService.THREAD_ID_MAIN): Boolean {
return realm.doesEventExistInChunkHistory(eventId) && realm.hasReadReceiptInLatestChunk(latestChunkEntity, roomId, userId, threadId)
}

private fun Realm.doesEventExistInChunkHistory(eventId: String): Boolean {
return ChunkEntity.findIncludingEvent(this, eventId) != null
}

private fun Realm.hasReadReceiptInLatestChunk(latestChunkEntity: ChunkEntity, roomId: String, userId: String): Boolean {
return ReadReceiptEntity.where(this, roomId = roomId, userId = userId).findFirst()?.let {
private fun Realm.hasReadReceiptInLatestChunk(latestChunkEntity: ChunkEntity, roomId: String, userId: String, threadId: String?): Boolean {
return ReadReceiptEntity.where(this, roomId = roomId, userId = userId, threadId = threadId).findFirst()?.let {
latestChunkEntity.timelineEvents.find(it.eventId)
} != null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,20 @@ import io.realm.Realm
import io.realm.RealmQuery
import io.realm.kotlin.createObject
import io.realm.kotlin.where
import org.matrix.android.sdk.api.session.room.read.ReadService
import org.matrix.android.sdk.internal.database.model.ReadReceiptEntity
import org.matrix.android.sdk.internal.database.model.ReadReceiptEntityFields

internal fun ReadReceiptEntity.Companion.where(realm: Realm, roomId: String, userId: String): RealmQuery<ReadReceiptEntity> {
internal fun ReadReceiptEntity.Companion.where(realm: Realm, roomId: String, userId: String, threadId: String?): RealmQuery<ReadReceiptEntity> {
return realm.where<ReadReceiptEntity>()
.equalTo(ReadReceiptEntityFields.PRIMARY_KEY, buildPrimaryKey(roomId, userId))
.equalTo(ReadReceiptEntityFields.PRIMARY_KEY, buildPrimaryKey(roomId, userId, threadId))
}

internal fun ReadReceiptEntity.Companion.forMainTimelineWhere(realm: Realm, roomId: String, userId: String): RealmQuery<ReadReceiptEntity> {
return realm.where<ReadReceiptEntity>()
.equalTo(ReadReceiptEntityFields.PRIMARY_KEY, buildPrimaryKey(roomId, userId, ReadService.THREAD_ID_MAIN))
.or()
.equalTo(ReadReceiptEntityFields.PRIMARY_KEY, buildPrimaryKey(roomId, userId, null))
}

internal fun ReadReceiptEntity.Companion.whereUserId(realm: Realm, userId: String): RealmQuery<ReadReceiptEntity> {
Expand All @@ -38,23 +46,37 @@ internal fun ReadReceiptEntity.Companion.whereRoomId(realm: Realm, roomId: Strin
.equalTo(ReadReceiptEntityFields.ROOM_ID, roomId)
}

internal fun ReadReceiptEntity.Companion.createUnmanaged(roomId: String, eventId: String, userId: String, originServerTs: Double): ReadReceiptEntity {
internal fun ReadReceiptEntity.Companion.createUnmanaged(
roomId: String,
eventId: String,
userId: String,
threadId: String?,
originServerTs: Double
): ReadReceiptEntity {
return ReadReceiptEntity().apply {
this.primaryKey = "${roomId}_$userId"
this.primaryKey = buildPrimaryKey(roomId, userId, threadId)
this.eventId = eventId
this.roomId = roomId
this.userId = userId
this.threadId = threadId
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>(buildPrimaryKey(roomId, userId))
internal fun ReadReceiptEntity.Companion.getOrCreate(realm: Realm, roomId: String, userId: String, threadId: String?): ReadReceiptEntity {
return ReadReceiptEntity.where(realm, roomId, userId, threadId).findFirst()
?: realm.createObject<ReadReceiptEntity>(buildPrimaryKey(roomId, userId, threadId))
.apply {
this.roomId = roomId
this.userId = userId
this.threadId = threadId
}
}

private fun buildPrimaryKey(roomId: String, userId: String) = "${roomId}_$userId"
private fun buildPrimaryKey(roomId: String, userId: String, threadId: String?): String {
return if (threadId == null) {
"${roomId}_${userId}"
} else {
"${roomId}_${userId}_${threadId}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import org.matrix.android.sdk.internal.session.room.membership.RoomMembersRespon
import org.matrix.android.sdk.internal.session.room.membership.admin.UserIdAndReason
import org.matrix.android.sdk.internal.session.room.membership.joining.InviteBody
import org.matrix.android.sdk.internal.session.room.membership.threepid.ThreePidInviteBody
import org.matrix.android.sdk.internal.session.room.read.ReadBody
import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse
import org.matrix.android.sdk.internal.session.room.reporting.ReportContentBody
import org.matrix.android.sdk.internal.session.room.send.SendResponse
Expand Down Expand Up @@ -173,7 +174,7 @@ internal interface RoomAPI {
@Path("roomId") roomId: String,
@Path("receiptType") receiptType: String,
@Path("eventId") eventId: String,
@Body body: JsonDict = emptyMap()
@Body body: ReadBody
)

/**
Expand Down
Loading