diff --git a/changelog.d/6996.sdk b/changelog.d/6996.sdk new file mode 100644 index 0000000000..588ec160d7 --- /dev/null +++ b/changelog.d/6996.sdk @@ -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) diff --git a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt index a6b4cc98a6..7ad342b22f 100644 --- a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt +++ b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt @@ -100,8 +100,8 @@ class FlowRoom(private val room: Room) { return room.readService().getReadMarkerLive().asFlow() } - fun liveReadReceipt(): Flow> { - return room.readService().getMyReadReceiptLive().asFlow() + fun liveReadReceipt(threadId: String?): Flow> { + return room.readService().getMyReadReceiptLive(threadId).asFlow() } fun liveEventReadReceipts(eventId: String): Flow> { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt index 5639730219..da7e4ea928 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt @@ -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? ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/read/ReadService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/read/ReadService.kt index dac1a1a773..83680ec2d8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/read/ReadService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/read/ReadService.kt @@ -34,12 +34,14 @@ 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. + * @param eventId the id of the event where read receipt will be set + * @param threadId the id of the thread in which read receipt will be set. For main thread use [ReadService.THREAD_ID_MAIN] constant */ - suspend fun setReadReceipt(eventId: String) + suspend fun setReadReceipt(eventId: String, threadId: String) /** * Set the read marker on the event with provided eventId. @@ -59,10 +61,10 @@ interface ReadService { /** * Returns a live read receipt id for the room. */ - fun getMyReadReceiptLive(): LiveData> + fun getMyReadReceiptLive(threadId: String?): LiveData> /** - * 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 @@ -74,4 +76,8 @@ interface ReadService { * @param eventId the event */ fun getEventReadReceiptsLive(eventId: String): LiveData> + + companion object { + const val THREAD_ID_MAIN = "main" + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt index 149a2eebfe..43f84e771a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt @@ -132,7 +132,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() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt index dfac7f6708..7999a2ea14 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt @@ -65,11 +65,11 @@ internal fun Map.updateThreadSummaryIfNeeded( inThreadMessages = inThreadMessages, latestMessageTimelineEventEntity = latestEventInThread ) - } - } - if (shouldUpdateNotifications) { - updateNotificationsNew(roomId, realm, currentUserId) + if (shouldUpdateNotifications) { + updateThreadNotifications(roomId, realm, currentUserId, rootThreadEventId) + } + } } } @@ -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 @@ -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 @@ -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 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() - // 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 + } - 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 } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt index 2be4510b6f..3b71ae3dea 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt @@ -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) } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ReadReceiptEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ReadReceiptEntity.kt index 9623c95359..cedd5e7424 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ReadReceiptEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ReadReceiptEntity.kt @@ -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 diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt index c8f22dc2cc..1deca47b70 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt @@ -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( @@ -52,3 +53,7 @@ internal fun TimelineEventEntity.deleteOnCascade(canDeleteRoot: Boolean) { } deleteFromRealm() } + +internal fun TimelineEventEntity.getThreadId(): String { + return root?.rootThreadEventId ?: ReadService.THREAD_ID_MAIN +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt index 0b0f01a67d..ebfe23105e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt @@ -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 @@ -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 } } @@ -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 } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadReceiptEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadReceiptEntityQueries.kt index 170814d3f2..0f9f56b938 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadReceiptEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadReceiptEntityQueries.kt @@ -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 { +internal fun ReadReceiptEntity.Companion.where(realm: Realm, roomId: String, userId: String, threadId: String?): RealmQuery { return realm.where() - .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 { + return realm.where() + .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 { @@ -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(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(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}" + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt index 9bcb7b8e4c..31bed90b62 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt @@ -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 @@ -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 ) /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt index b30c66c82e..36ec5e8dac 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt @@ -30,17 +30,20 @@ import org.matrix.android.sdk.internal.database.mapper.ReadReceiptsSummaryMapper 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.ReadReceiptsSummaryEntity +import org.matrix.android.sdk.internal.database.query.forMainTimelineWhere import org.matrix.android.sdk.internal.database.query.isEventRead import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.session.homeserver.HomeServerCapabilitiesDataSource internal class DefaultReadService @AssistedInject constructor( @Assisted private val roomId: String, @SessionDatabase private val monarchy: Monarchy, private val setReadMarkersTask: SetReadMarkersTask, private val readReceiptsSummaryMapper: ReadReceiptsSummaryMapper, - @UserId private val userId: String + @UserId private val userId: String, + private val homeServerCapabilitiesDataSource: HomeServerCapabilitiesDataSource ) : ReadService { @AssistedFactory @@ -48,17 +51,28 @@ internal class DefaultReadService @AssistedInject constructor( fun create(roomId: String): DefaultReadService } - override suspend fun markAsRead(params: ReadService.MarkAsReadParams) { + override suspend fun markAsRead(params: ReadService.MarkAsReadParams, mainTimeLineOnly: Boolean) { + val readReceiptThreadId = if (homeServerCapabilitiesDataSource.getHomeServerCapabilities()?.canUseThreadReadReceiptsAndNotifications == true) { + if (mainTimeLineOnly) ReadService.THREAD_ID_MAIN else null + } else { + null + } val taskParams = SetReadMarkersTask.Params( roomId = roomId, forceReadMarker = params.forceReadMarker(), - forceReadReceipt = params.forceReadReceipt() + forceReadReceipt = params.forceReadReceipt(), + readReceiptThreadId = readReceiptThreadId ) setReadMarkersTask.execute(taskParams) } - override suspend fun setReadReceipt(eventId: String) { - val params = SetReadMarkersTask.Params(roomId, fullyReadEventId = null, readReceiptEventId = eventId) + override suspend fun setReadReceipt(eventId: String, threadId: String) { + val readReceiptThreadId = if (homeServerCapabilitiesDataSource.getHomeServerCapabilities()?.canUseThreadReadReceiptsAndNotifications == true) { + threadId + } else { + null + } + val params = SetReadMarkersTask.Params(roomId, fullyReadEventId = null, readReceiptEventId = eventId, readReceiptThreadId = readReceiptThreadId) setReadMarkersTask.execute(params) } @@ -68,7 +82,8 @@ internal class DefaultReadService @AssistedInject constructor( } override fun isEventRead(eventId: String): Boolean { - return isEventRead(monarchy.realmConfiguration, userId, roomId, eventId) + val shouldCheckIfReadInEventsThread = homeServerCapabilitiesDataSource.getHomeServerCapabilities()?.canUseThreadReadReceiptsAndNotifications == true + return isEventRead(monarchy.realmConfiguration, userId, roomId, eventId, shouldCheckIfReadInEventsThread) } override fun getReadMarkerLive(): LiveData> { @@ -81,9 +96,9 @@ internal class DefaultReadService @AssistedInject constructor( } } - override fun getMyReadReceiptLive(): LiveData> { + override fun getMyReadReceiptLive(threadId: String?): LiveData> { val liveRealmData = monarchy.findAllMappedWithChanges( - { ReadReceiptEntity.where(it, roomId = roomId, userId = userId) }, + { ReadReceiptEntity.where(it, roomId = roomId, userId = userId, threadId = threadId) }, { it.eventId } ) return Transformations.map(liveRealmData) { @@ -94,10 +109,11 @@ internal class DefaultReadService @AssistedInject constructor( override fun getUserReadReceipt(userId: String): String? { var eventId: String? = null monarchy.doWithRealm { - eventId = ReadReceiptEntity.where(it, roomId = roomId, userId = userId) + eventId = ReadReceiptEntity.forMainTimelineWhere(it, roomId = roomId, userId = userId) .findFirst() ?.eventId } + return eventId } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/ReadBody.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/ReadBody.kt new file mode 100644 index 0000000000..9374de5d5f --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/ReadBody.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.room.read + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +internal data class ReadBody( + @Json(name = "thread_id") val threadId: String?, +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/SetReadMarkersTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/SetReadMarkersTask.kt index a124a8a4c2..8e7592a8b4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/SetReadMarkersTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/SetReadMarkersTask.kt @@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.session.room.read import com.zhuinden.monarchy.Monarchy import io.realm.Realm import org.matrix.android.sdk.api.session.events.model.LocalEcho +import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.query.isEventRead @@ -45,8 +46,9 @@ internal interface SetReadMarkersTask : Task { val roomId: String, val fullyReadEventId: String? = null, val readReceiptEventId: String? = null, + val readReceiptThreadId: String? = null, val forceReadReceipt: Boolean = false, - val forceReadMarker: Boolean = false + val forceReadMarker: Boolean = false, ) } @@ -61,12 +63,14 @@ internal class DefaultSetReadMarkersTask @Inject constructor( @UserId private val userId: String, private val globalErrorReceiver: GlobalErrorReceiver, private val clock: Clock, + private val homeServerCapabilitiesService: HomeServerCapabilitiesService, ) : SetReadMarkersTask { override suspend fun execute(params: SetReadMarkersTask.Params) { val markers = mutableMapOf() Timber.v("Execute set read marker with params: $params") val latestSyncedEventId = latestSyncedEventId(params.roomId) + val readReceiptThreadId = params.readReceiptThreadId val fullyReadEventId = if (params.forceReadMarker) { latestSyncedEventId } else { @@ -77,6 +81,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor( } else { params.readReceiptEventId } + if (fullyReadEventId != null && !isReadMarkerMoreRecent(monarchy.realmConfiguration, params.roomId, fullyReadEventId)) { if (LocalEcho.isLocalEchoId(fullyReadEventId)) { Timber.w("Can't set read marker for local event $fullyReadEventId") @@ -84,8 +89,12 @@ internal class DefaultSetReadMarkersTask @Inject constructor( markers[READ_MARKER] = fullyReadEventId } } + + val shouldCheckIfReadInEventsThread = readReceiptThreadId != null && + homeServerCapabilitiesService.getHomeServerCapabilities().canUseThreadReadReceiptsAndNotifications + if (readReceiptEventId != null && - !isEventRead(monarchy.realmConfiguration, userId, params.roomId, readReceiptEventId)) { + !isEventRead(monarchy.realmConfiguration, userId, params.roomId, readReceiptEventId, shouldCheckIfReadInEventsThread)) { if (LocalEcho.isLocalEchoId(readReceiptEventId)) { Timber.w("Can't set read receipt for local event $readReceiptEventId") } else { @@ -95,7 +104,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor( val shouldUpdateRoomSummary = readReceiptEventId != null && readReceiptEventId == latestSyncedEventId if (markers.isNotEmpty() || shouldUpdateRoomSummary) { - updateDatabase(params.roomId, markers, shouldUpdateRoomSummary) + updateDatabase(params.roomId, readReceiptThreadId, markers, shouldUpdateRoomSummary) } if (markers.isNotEmpty()) { executeRequest( @@ -104,7 +113,8 @@ internal class DefaultSetReadMarkersTask @Inject constructor( ) { if (markers[READ_MARKER] == null) { if (readReceiptEventId != null) { - roomAPI.sendReceipt(params.roomId, READ_RECEIPT, readReceiptEventId) + val readBody = ReadBody(threadId = params.readReceiptThreadId) + roomAPI.sendReceipt(params.roomId, READ_RECEIPT, readReceiptEventId, readBody) } } else { // "m.fully_read" value is mandatory to make this call @@ -119,7 +129,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor( TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId } - private suspend fun updateDatabase(roomId: String, markers: Map, shouldUpdateRoomSummary: Boolean) { + private suspend fun updateDatabase(roomId: String, readReceiptThreadId: String?, markers: Map, shouldUpdateRoomSummary: Boolean) { monarchy.awaitTransaction { realm -> val readMarkerId = markers[READ_MARKER] val readReceiptId = markers[READ_RECEIPT] @@ -127,7 +137,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor( roomFullyReadHandler.handle(realm, roomId, FullyReadContent(readMarkerId)) } if (readReceiptId != null) { - val readReceiptContent = ReadReceiptHandler.createContent(userId, readReceiptId, clock.epochMillis()) + val readReceiptContent = ReadReceiptHandler.createContent(userId, readReceiptId, readReceiptThreadId, clock.epochMillis()) readReceiptHandler.handle(realm, roomId, readReceiptContent, false, null) } if (shouldUpdateRoomSummary) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt index 7c83a4afa7..21a0862c65 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt @@ -24,6 +24,7 @@ import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.content.EncryptionEventContent import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.api.session.room.accountdata.RoomAccountDataTypes import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent @@ -75,7 +76,8 @@ internal class RoomSummaryUpdater @Inject constructor( private val roomAvatarResolver: RoomAvatarResolver, private val eventDecryptor: EventDecryptor, private val crossSigningService: DefaultCrossSigningService, - private val roomAccountDataDataSource: RoomAccountDataDataSource + private val roomAccountDataDataSource: RoomAccountDataDataSource, + private val homeServerCapabilitiesService: HomeServerCapabilitiesService, ) { fun refreshLatestPreviewContent(realm: Realm, roomId: String) { @@ -151,9 +153,11 @@ internal class RoomSummaryUpdater @Inject constructor( latestPreviewableEvent.attemptToDecrypt() } + val shouldCheckIfReadInEventsThread = homeServerCapabilitiesService.getHomeServerCapabilities().canUseThreadReadReceiptsAndNotifications + roomSummaryEntity.hasUnreadMessages = roomSummaryEntity.notificationCount > 0 || // avoid this call if we are sure there are unread events - latestPreviewableEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId) } ?: false + latestPreviewableEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId, shouldCheckIfReadInEventsThread) } ?: false roomSummaryEntity.setDisplayName(roomDisplayNameResolver.resolve(realm, roomId)) roomSummaryEntity.avatarUrl = roomAvatarResolver.resolve(realm, roomId) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt index c380ccf14f..0854cc5cf4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt @@ -411,7 +411,7 @@ internal class DefaultTimeline( private fun ensureReadReceiptAreLoaded(realm: Realm) { readReceiptHandler.getContentFromInitSync(roomId) ?.also { - Timber.w("INIT_SYNC Insert when opening timeline RR for room $roomId") + Timber.d("INIT_SYNC Insert when opening timeline RR for room $roomId") } ?.let { readReceiptContent -> realm.executeTransactionAsync { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ReadReceiptHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ReadReceiptHandler.kt index 7329611a01..7f12ce653c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ReadReceiptHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ReadReceiptHandler.kt @@ -33,10 +33,11 @@ import javax.inject.Inject // value : dict key $UserId // value dict key ts // dict value ts value -internal typealias ReadReceiptContent = Map>>> +internal typealias ReadReceiptContent = Map>>> private const val READ_KEY = "m.read" private const val TIMESTAMP_KEY = "ts" +private const val THREAD_ID_KEY = "thread_id" internal class ReadReceiptHandler @Inject constructor( private val roomSyncEphemeralTemporaryStore: RoomSyncEphemeralTemporaryStore @@ -47,14 +48,19 @@ internal class ReadReceiptHandler @Inject constructor( fun createContent( userId: String, eventId: String, + threadId: String?, currentTimeMillis: Long ): ReadReceiptContent { + val userReadReceipt = mutableMapOf( + TIMESTAMP_KEY to currentTimeMillis.toDouble(), + ) + threadId?.let { + userReadReceipt.put(THREAD_ID_KEY, threadId) + } return mapOf( eventId to mapOf( READ_KEY to mapOf( - userId to mapOf( - TIMESTAMP_KEY to currentTimeMillis.toDouble() - ) + userId to userReadReceipt ) ) ) @@ -98,8 +104,9 @@ internal class ReadReceiptHandler @Inject constructor( 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) + val ts = paramsDict[TIMESTAMP_KEY] as? Double ?: 0.0 + val threadId = paramsDict[THREAD_ID_KEY] as String? + val receiptEntity = ReadReceiptEntity.createUnmanaged(roomId, eventId, userId, threadId, ts) readReceiptsSummary.readReceipts.add(receiptEntity) } readReceiptSummaries.add(readReceiptsSummary) @@ -115,7 +122,7 @@ internal class ReadReceiptHandler @Inject constructor( ) { // First check if we have data from init sync to handle getContentFromInitSync(roomId)?.let { - Timber.w("INIT_SYNC Insert during incremental sync RR for room $roomId") + Timber.d("INIT_SYNC Insert during incremental sync RR for room $roomId") doIncrementalSyncStrategy(realm, roomId, it) aggregator?.ephemeralFilesToDelete?.add(roomId) } @@ -132,8 +139,9 @@ internal class ReadReceiptHandler @Inject constructor( } for ((userId, paramsDict) in userIdsDict) { - val ts = paramsDict[TIMESTAMP_KEY] ?: 0.0 - val receiptEntity = ReadReceiptEntity.getOrCreate(realm, roomId, userId) + val ts = paramsDict[TIMESTAMP_KEY] as? Double ?: 0.0 + val threadId = paramsDict[THREAD_ID_KEY] as String? + val receiptEntity = ReadReceiptEntity.getOrCreate(realm, roomId, userId, threadId) // ensure new ts is superior to the previous one if (ts > receiptEntity.originServerTs) { ReadReceiptsSummaryEntity.where(realm, receiptEntity.eventId).findFirst()?.also { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index ef238d56e6..02782783b8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -217,7 +217,7 @@ class TimelineViewModel @AssistedInject constructor( observePowerLevel() setupPreviewUrlObservers() viewModelScope.launch(Dispatchers.IO) { - tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT) } + tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = true) } } // Inform the SDK that the room is displayed viewModelScope.launch(Dispatchers.IO) { @@ -1103,7 +1103,8 @@ class TimelineViewModel @AssistedInject constructor( } bufferedMostRecentDisplayedEvent.root.eventId?.let { eventId -> session.coroutineScope.launch { - tryOrNull { room.readService().setReadReceipt(eventId) } + val threadId = initialState.rootThreadEventId ?: ReadService.THREAD_ID_MAIN + tryOrNull { room.readService().setReadReceipt(eventId, threadId = threadId) } } } } @@ -1121,7 +1122,7 @@ class TimelineViewModel @AssistedInject constructor( if (room == null) return setState { copy(unreadState = UnreadState.HasNoUnread) } viewModelScope.launch { - tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.BOTH) } + tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.BOTH, mainTimeLineOnly = true) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt index 1f079e420b..f845a42dcd 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt @@ -74,6 +74,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent +import org.matrix.android.sdk.api.session.room.read.ReadService import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import timber.log.Timber @@ -516,7 +517,7 @@ class TimelineEventController @Inject constructor( event.eventId, readReceipts, callback, - partialState.isFromThreadTimeline() + partialState.isFromThreadTimeline(), ), formattedDayModel = formattedDayModel, mergedHeaderModel = mergedHeaderModel @@ -559,7 +560,7 @@ class TimelineEventController @Inject constructor( val event = itr.previous() timelineEventsGroups.addOrIgnore(event) val currentReadReceipts = ArrayList(event.readReceipts).filter { - it.roomMember.userId != session.myUserId + it.roomMember.userId != session.myUserId && it.isVisibleInThisThread() } if (timelineEventVisibilityHelper.shouldShowEvent( timelineEvent = event, @@ -577,6 +578,14 @@ class TimelineEventController @Inject constructor( } } + private fun ReadReceipt.isVisibleInThisThread(): Boolean { + return if (partialState.isFromThreadTimeline()) { + this.threadId == partialState.rootThreadEventId + } else { + this.threadId == null || this.threadId == ReadService.THREAD_ID_MAIN + } + } + private fun buildDaySeparatorItem(originServerTs: Long?): DaySeparatorItem { val formattedDay = dateFormatter.format(originServerTs, DateFormatKind.TIMELINE_DAY_DIVIDER) return DaySeparatorItem_().formattedDay(formattedDay).id(formattedDay) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt index 6a711ec2dc..8607af6891 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt @@ -21,16 +21,20 @@ import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem_ +import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.room.model.ReadReceipt import javax.inject.Inject -class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: AvatarRenderer) { +class ReadReceiptsItemFactory @Inject constructor( + private val avatarRenderer: AvatarRenderer, + private val session: Session +) { fun create( eventId: String, readReceipts: List, callback: TimelineEventController.Callback?, - isFromThreadTimeLine: Boolean + isFromThreadTimeLine: Boolean, ): ReadReceiptsItem? { if (readReceipts.isEmpty()) { return null @@ -40,12 +44,13 @@ class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: Av ReadReceiptData(it.roomMember.userId, it.roomMember.avatarUrl, it.roomMember.displayName, it.originServerTs) } .sortedByDescending { it.timestamp } + val threadReadReceiptsSupported = session.homeServerCapabilitiesService().getHomeServerCapabilities().canUseThreadReadReceiptsAndNotifications return ReadReceiptsItem_() .id("read_receipts_$eventId") .eventId(eventId) .readReceipts(readReceiptsData) .avatarRenderer(avatarRenderer) - .shouldHideReadReceipts(isFromThreadTimeLine) + .shouldHideReadReceipts(isFromThreadTimeLine && !threadReadReceiptsSupported) .clickListener { callback?.onReadReceiptsClicked(readReceiptsData) } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt index 3fe0898eb4..180351f806 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt @@ -109,7 +109,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { val room = session.getRoom(roomId) if (room != null) { session.coroutineScope.launch { - tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT) } + tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) } } } }