diff --git a/changelog.d/5230.feature b/changelog.d/5230.feature new file mode 100644 index 00000000000..b333a3f2c7a --- /dev/null +++ b/changelog.d/5230.feature @@ -0,0 +1 @@ +Thread timeline is now live and much faster especially for large or old threads \ No newline at end of file diff --git a/changelog.d/5232.feature b/changelog.d/5232.feature new file mode 100644 index 00000000000..8f3bec97bd3 --- /dev/null +++ b/changelog.d/5232.feature @@ -0,0 +1 @@ +View all threads per room screen is now live when the home server supports threads \ No newline at end of file diff --git a/changelog.d/5271.sdk b/changelog.d/5271.sdk new file mode 100644 index 00000000000..b73d97ee4f5 --- /dev/null +++ b/changelog.d/5271.sdk @@ -0,0 +1 @@ +Adds support for MSC3440, additional threads homeserver capabilities \ No newline at end of file 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 c5d1d19fec5..d7377153065 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 @@ -28,6 +28,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState import org.matrix.android.sdk.api.session.room.send.UserDraft +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional @@ -101,13 +102,18 @@ class FlowRoom(private val room: Room) { return room.getLiveRoomNotificationState().asFlow() } + fun liveThreadSummaries(): Flow> { + return room.getAllThreadSummariesLive().asFlow() + .startWith(room.coroutineDispatchers.io) { + room.getAllThreadSummaries() + } + } fun liveThreadList(): Flow> { return room.getAllThreadsLive().asFlow() .startWith(room.coroutineDispatchers.io) { room.getAllThreads() } } - fun liveLocalUnreadThreadList(): Flow> { return room.getMarkedThreadNotificationsLive().asFlow() .startWith(room.coroutineDispatchers.io) { diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt index 69ae57e644f..5c011c8b2fb 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/ChunkEntityTest.kt @@ -62,7 +62,11 @@ internal class ChunkEntityTest : InstrumentedTest { val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let { realm.copyToRealm(it) } - chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap()) + chunk.addTimelineEvent( + roomId = ROOM_ID, + eventEntity = fakeEvent, + direction = PaginationDirection.FORWARDS, + roomMemberContentsByUser = emptyMap()) chunk.timelineEvents.size shouldBeEqualTo 1 } } @@ -74,8 +78,16 @@ internal class ChunkEntityTest : InstrumentedTest { val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let { realm.copyToRealm(it) } - chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap()) - chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap()) + chunk.addTimelineEvent( + roomId = ROOM_ID, + eventEntity = fakeEvent, + direction = PaginationDirection.FORWARDS, + roomMemberContentsByUser = emptyMap()) + chunk.addTimelineEvent( + roomId = ROOM_ID, + eventEntity = fakeEvent, + direction = PaginationDirection.FORWARDS, + roomMemberContentsByUser = emptyMap()) chunk.timelineEvents.size shouldBeEqualTo 1 } } @@ -144,7 +156,11 @@ internal class ChunkEntityTest : InstrumentedTest { val fakeEvent = event.toEntity(roomId, SendState.SYNCED, System.currentTimeMillis()).let { realm.copyToRealm(it) } - addTimelineEvent(roomId, fakeEvent, direction, emptyMap()) + addTimelineEvent( + roomId = roomId, + eventEntity = fakeEvent, + direction = direction, + roomMemberContentsByUser = emptyMap()) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt index 34096d603fd..ae8ed3941fa 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt @@ -49,5 +49,6 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class AggregatedRelations( @Json(name = "m.annotation") val annotations: AggregatedAnnotation? = null, - @Json(name = "m.reference") val references: DefaultUnsignedRelationInfo? = null + @Json(name = "m.reference") val references: DefaultUnsignedRelationInfo? = null, + @Json(name = RelationType.THREAD) val latestThread: LatestThreadUnsignedRelation? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index c0ca40bc730..2ef2dfd91ea 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt @@ -201,7 +201,11 @@ data class Event( */ fun getDecryptedTextSummary(): String? { if (isRedacted()) return "Message Deleted" - val text = getDecryptedValue() ?: return null + val text = getDecryptedValue() ?: run { + if (isPoll()) { return getPollQuestion() ?: "created a poll." } + return null + } + return when { isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text) isFileMessage() -> "sent a file." @@ -385,12 +389,12 @@ fun Event.isReply(): Boolean { } fun Event.isReplyRenderedInThread(): Boolean { - return isReply() && getRelationContent()?.inReplyTo?.shouldRenderInThread() == true + return isReply() && getRelationContent()?.shouldRenderInThread() == true } -fun Event.isThread(): Boolean = getRelationContentForType(RelationType.IO_THREAD)?.eventId != null +fun Event.isThread(): Boolean = getRelationContentForType(RelationType.THREAD)?.eventId != null -fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.IO_THREAD)?.eventId +fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.THREAD)?.eventId fun Event.isEdition(): Boolean { return getRelationContentForType(RelationType.REPLACE)?.eventId != null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/LatestThreadUnsignedRelation.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/LatestThreadUnsignedRelation.kt new file mode 100644 index 00000000000..cc52dfc02c1 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/LatestThreadUnsignedRelation.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2022 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.api.session.events.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class LatestThreadUnsignedRelation( + override val limited: Boolean? = false, + override val count: Int? = 0, + @Json(name = "latest_event") + val event: Event? = null, + @Json(name = "current_user_participated") + val isUserParticipating: Boolean? = false + +) : UnsignedRelationInfo diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt index fb26264ad77..74dc74b2949 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt @@ -30,7 +30,6 @@ object RelationType { /** Lets you define an event which is a thread reply to an existing event.*/ const val THREAD = "m.thread" - const val IO_THREAD = "io.element.thread" /** Lets you define an event which adds a response to an existing event.*/ const val RESPONSE = "org.matrix.response" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt index 2256dfb8f0c..9db3876b747 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt @@ -50,7 +50,11 @@ data class HomeServerCapabilities( * This capability describes the default and available room versions a server supports, and at what level of stability. * Clients should make use of this capability to determine if users need to be encouraged to upgrade their rooms. */ - val roomVersions: RoomVersionCapabilities? = null + val roomVersions: RoomVersionCapabilities? = null, + /** + * True if the home server support threading + */ + var canUseThreading: Boolean = false ) { enum class RoomCapabilitySupport { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt index d930a5d0fd6..be65b883b36 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt @@ -33,6 +33,7 @@ import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.session.room.tags.TagsService import org.matrix.android.sdk.api.session.room.threads.ThreadsService +import org.matrix.android.sdk.api.session.room.threads.local.ThreadsLocalService import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.uploads.UploadsService @@ -47,6 +48,7 @@ import org.matrix.android.sdk.api.util.Optional interface Room : TimelineService, ThreadsService, + ThreadsLocalService, SendService, DraftService, ReadService, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReactionInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReactionInfo.kt index 733d6c37e86..e7bebeeff66 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReactionInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReactionInfo.kt @@ -26,5 +26,6 @@ data class ReactionInfo( @Json(name = "key") val key: String, // always null for reaction @Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null, - @Json(name = "option") override val option: Int? = null + @Json(name = "option") override val option: Int? = null, + @Json(name = "is_falling_back") override val isFallingBack: Boolean? = null ) : RelationContent diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationContent.kt index e2080bb4376..53b1fea873d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationContent.kt @@ -24,4 +24,10 @@ interface RelationContent { val eventId: String? val inReplyTo: ReplyToContent? val option: Int? + + /** + * This flag indicates that the message should be rendered as a reply + * fallback, when isFallingBack = false + */ + val isFallingBack: Boolean? } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt index 10b071a6013..5dcb1b4323b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt @@ -23,5 +23,8 @@ data class RelationDefaultContent( @Json(name = "rel_type") override val type: String?, @Json(name = "event_id") override val eventId: String?, @Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null, - @Json(name = "option") override val option: Int? = null + @Json(name = "option") override val option: Int? = null, + @Json(name = "is_falling_back") override val isFallingBack: Boolean? = null ) : RelationContent + +fun RelationDefaultContent.shouldRenderInThread(): Boolean = isFallingBack == false diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt index 09114436f04..44098989084 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt @@ -163,13 +163,4 @@ interface RelationService { autoMarkdown: Boolean = false, formattedText: String? = null, eventReplied: TimelineEvent? = null): Cancelable? - - /** - * Get all the thread replies for the specified rootThreadEventId - * The return list will contain the original root thread event and all the thread replies to that event - * Note: We will use a large limit value in order to avoid using pagination until it would be 100% ready - * from the backend - * @param rootThreadEventId the root thread eventId - */ - suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt index 412a1bfca9d..251328bea21 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt @@ -21,8 +21,5 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class ReplyToContent( - @Json(name = "event_id") val eventId: String? = null, - @Json(name = "render_in") val renderIn: List? = null + @Json(name = "event_id") val eventId: String? = null ) - -fun ReplyToContent.shouldRenderInThread(): Boolean = renderIn?.contains("m.thread") == true diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt index e4d1d979e1a..839cdff63ba 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt @@ -17,51 +17,43 @@ package org.matrix.android.sdk.api.session.room.threads import androidx.lifecycle.LiveData -import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary /** - * This interface defines methods to interact with threads related features. - * It's implemented at the room level within the main timeline. + * This interface defines methods to interact with thread related features. + * It's the dynamic threads implementation and the homeserver must return + * a capability entry for threads. If the server do not support m.thread + * then [ThreadsLocalService] should be used instead */ interface ThreadsService { /** - * Returns a [LiveData] list of all the thread root TimelineEvents that exists at the room level + * Returns a [LiveData] list of all the [ThreadSummary] that exists at the room level */ - fun getAllThreadsLive(): LiveData> + fun getAllThreadSummariesLive(): LiveData> /** - * Returns a list of all the thread root TimelineEvents that exists at the room level + * Returns a list of all the [ThreadSummary] that exists at the room level */ - fun getAllThreads(): List + fun getAllThreadSummaries(): List /** - * Returns a [LiveData] list of all the marked unread threads that exists at the room level - */ - fun getMarkedThreadNotificationsLive(): LiveData> - - /** - * Returns a list of all the marked unread threads that exists at the room level - */ - fun getMarkedThreadNotifications(): List - - /** - * Returns whether or not the current user is participating in the thread - * @param rootThreadEventId the eventId of the current thread + * Enhance the provided ThreadSummary[List] by adding the latest + * message edition for that thread + * @return the enhanced [List] with edited updates */ - fun isUserParticipatingInThread(rootThreadEventId: String): Boolean + fun enhanceThreadWithEditions(threads: List): List /** - * Enhance the provided root thread TimelineEvent [List] by adding the latest - * message edition for that thread - * @return the enhanced [List] with edited updates + * Fetch all thread replies for the specified thread using the /relations api + * @param rootThreadEventId the root thread eventId + * @param from defines the token that will fetch from that position + * @param limit defines the number of max results the api will respond with */ - fun mapEventsWithEdition(threads: List): List + suspend fun fetchThreadTimeline(rootThreadEventId: String, from: String, limit: Int) /** - * Marks the current thread as read in local DB. - * note: read receipts within threads are not yet supported with the API - * @param rootThreadEventId the root eventId of the current thread + * Fetch all thread summaries for the current room using the enhanced /messages api */ - suspend fun markThreadAsRead(rootThreadEventId: String) + suspend fun fetchThreadSummaries() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/local/ThreadsLocalService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/local/ThreadsLocalService.kt new file mode 100644 index 00000000000..f7b379e3821 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/local/ThreadsLocalService.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2022 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.api.session.room.threads.local + +import androidx.lifecycle.LiveData +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +/** + * This interface defines methods to interact with thread related features. + * It's the local threads implementation and assumes that the homeserver + * do not support threads + */ +interface ThreadsLocalService { + + /** + * Returns a [LiveData] list of all the thread root TimelineEvents that exists at the room level + */ + fun getAllThreadsLive(): LiveData> + + /** + * Returns a list of all the thread root TimelineEvents that exists at the room level + */ + fun getAllThreads(): List + + /** + * Returns a [LiveData] list of all the marked unread threads that exists at the room level + */ + fun getMarkedThreadNotificationsLive(): LiveData> + + /** + * Returns a list of all the marked unread threads that exists at the room level + */ + fun getMarkedThreadNotifications(): List + + /** + * Returns whether or not the current user is participating in the thread + * @param rootThreadEventId the eventId of the current thread + */ + fun isUserParticipatingInThread(rootThreadEventId: String): Boolean + + /** + * Enhance the provided root thread TimelineEvent [List] by adding the latest + * message edition for that thread + * @return the enhanced [List] with edited updates + */ + fun mapEventsWithEdition(threads: List): List + + /** + * Marks the current thread as read in local DB. + * note: read receipts within threads are not yet supported with the API + * @param rootThreadEventId the root eventId of the current thread + */ + suspend fun markThreadAsRead(rootThreadEventId: String) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadEditions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadEditions.kt new file mode 100644 index 00000000000..c8353cf0de0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadEditions.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2022 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.api.session.room.threads.model + +data class ThreadEditions(var rootThreadEdition: String? = null, + var latestThreadEdition: String? = null) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummary.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummary.kt new file mode 100644 index 00000000000..017afba1bae --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummary.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2022 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.api.session.room.threads.model + +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.room.sender.SenderInfo + +/** + * The main thread Summary model, mainly used to display the thread list + */ +data class ThreadSummary(val roomId: String, + val rootEvent: Event?, + val latestEvent: Event?, + val rootEventId: String, + val rootThreadSenderInfo: SenderInfo, + val latestThreadSenderInfo: SenderInfo, + val isUserParticipating: Boolean, + val numberOfThreads: Int, + val threadEditions: ThreadEditions = ThreadEditions()) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummaryUpdateType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummaryUpdateType.kt new file mode 100644 index 00000000000..95697f987f0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/model/ThreadSummaryUpdateType.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2022 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.api.session.room.threads.model + +enum class ThreadSummaryUpdateType { + REPLACE, + ADD +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index f9398ac7b85..01686ca04b6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt @@ -54,6 +54,7 @@ data class TimelineEvent( * It's not unique on the timeline as it's reset on each chunk. */ val displayIndex: Int, + var ownedByThreadChunk: Boolean = false, val senderInfo: SenderInfo, val annotations: EventAnnotationsSummary? = null, val readReceipts: List = emptyList() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt index fafe17b2c09..d6937d5b265 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt @@ -16,6 +16,7 @@ package org.matrix.android.sdk.api.session.threads +import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.sender.SenderInfo /** @@ -26,7 +27,7 @@ data class ThreadDetails( val isRootThread: Boolean = false, val numberOfThreads: Int = 0, val threadSummarySenderInfo: SenderInfo? = null, - val threadSummaryLatestTextMessage: String? = null, + val threadSummaryLatestEvent: Event? = null, val lastMessageTimestamp: Long? = null, var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE, val isThread: Boolean = false, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt index 302f7387fad..650b8cc26db 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/util/MatrixItem.kt @@ -17,6 +17,7 @@ package org.matrix.android.sdk.api.util import org.matrix.android.sdk.BuildConfig +import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.group.model.GroupSummary import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary @@ -199,6 +200,8 @@ fun RoomMemberSummary.toMatrixItem() = MatrixItem.UserItem(userId, displayName, fun SenderInfo.toMatrixItem() = MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl) +fun SenderInfo.toMatrixItemOrNull() = tryOrNull { MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl) } + fun SpaceChildInfo.toMatrixItem() = if (roomType == RoomType.SPACE) { MatrixItem.SpaceItem(childRoomId, name ?: canonicalAlias, avatarUrl) } else { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt index 0a9b8b73cc1..815f8de2de2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/HomeServerVersion.kt @@ -38,7 +38,7 @@ internal data class HomeServerVersion( } companion object { - internal val pattern = Regex("""r(\d+)\.(\d+)\.(\d+)""") + internal val pattern = Regex("""[r|v](\d+)\.(\d+)\.(\d+)""") internal fun parse(value: String): HomeServerVersion? { val result = pattern.matchEntire(value) ?: return null @@ -56,5 +56,6 @@ internal data class HomeServerVersion( val r0_4_0 = HomeServerVersion(major = 0, minor = 4, patch = 0) val r0_5_0 = HomeServerVersion(major = 0, minor = 5, patch = 0) val r0_6_0 = HomeServerVersion(major = 0, minor = 6, patch = 0) + val v1_3_0 = HomeServerVersion(major = 1, minor = 3, patch = 0) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt index 74cb3de2acf..d07d5ecd64b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt @@ -51,6 +51,8 @@ private const val FEATURE_LAZY_LOAD_MEMBERS = "m.lazy_load_members" private const val FEATURE_REQUIRE_IDENTITY_SERVER = "m.require_identity_server" private const val FEATURE_ID_ACCESS_TOKEN = "m.id_access_token" private const val FEATURE_SEPARATE_ADD_AND_BIND = "m.separate_add_and_bind" +private const val FEATURE_THREADS_MSC3440 = "org.matrix.msc3440" +private const val FEATURE_THREADS_MSC3440_STABLE = "org.matrix.msc3440.stable" /** * Return true if the SDK supports this homeserver version @@ -68,6 +70,14 @@ internal fun Versions.isLoginAndRegistrationSupportedBySdk(): Boolean { doesServerSeparatesAddAndBind() } +/** + * Indicate if the homeserver support MSC3440 for threads + */ +internal fun Versions.doesServerSupportThreads(): Boolean { + return getMaxVersion() >= HomeServerVersion.v1_3_0 || + unstableFeatures?.get(FEATURE_THREADS_MSC3440_STABLE) ?: false +} + /** * Return true if the server support the lazy loading of room members * diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index 12e60da1145..a57397dad5f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -43,6 +43,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo022 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo023 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo024 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo025 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo026 import org.matrix.android.sdk.internal.util.Normalizer import timber.log.Timber import javax.inject.Inject @@ -57,7 +58,7 @@ internal class RealmSessionStoreMigration @Inject constructor( override fun equals(other: Any?) = other is RealmSessionStoreMigration override fun hashCode() = 1000 - val schemaVersion = 25L + val schemaVersion = 26L override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { Timber.d("Migrating Realm Session from $oldVersion to $newVersion") @@ -87,5 +88,6 @@ internal class RealmSessionStoreMigration @Inject constructor( if (oldVersion < 23) MigrateSessionTo023(realm).perform() if (oldVersion < 24) MigrateSessionTo024(realm).perform() if (oldVersion < 25) MigrateSessionTo025(realm).perform() + if (oldVersion < 26) MigrateSessionTo026(realm).perform() } } 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 289db9fa15b..d2e3e99b755 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 @@ -82,17 +82,18 @@ internal fun ChunkEntity.addStateEvent(roomId: String, stateEvent: EventEntity, internal fun ChunkEntity.addTimelineEvent(roomId: String, eventEntity: EventEntity, direction: PaginationDirection, - roomMemberContentsByUser: Map? = null) { + ownedByThreadChunk: Boolean = false, + roomMemberContentsByUser: Map? = null): TimelineEventEntity? { val eventId = eventEntity.eventId if (timelineEvents.find(eventId) != null) { - return + return null } val displayIndex = nextDisplayIndex(direction) val localId = TimelineEventEntity.nextId(realm) val senderId = eventEntity.sender ?: "" // Update RR for the sender of a new message with a dummy one - val readReceiptsSummaryEntity = handleReadReceipts(realm, roomId, eventEntity, senderId) + val readReceiptsSummaryEntity = if (!ownedByThreadChunk) handleReadReceipts(realm, roomId, eventEntity, senderId) else null val timelineEventEntity = realm.createObject().apply { this.localId = localId this.root = eventEntity @@ -102,6 +103,7 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String, ?.also { it.cleanUp(eventEntity.sender) } this.readReceipts = readReceiptsSummaryEntity this.displayIndex = displayIndex + this.ownedByThreadChunk = ownedByThreadChunk val roomMemberContent = roomMemberContentsByUser?.get(senderId) this.senderAvatar = roomMemberContent?.avatarUrl this.senderName = roomMemberContent?.displayName @@ -113,9 +115,10 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String, } // numberOfTimelineEvents++ timelineEvents.add(timelineEventEntity) + return timelineEventEntity } -private fun computeIsUnique( +fun computeIsUnique( realm: Realm, roomId: String, isLastForward: Boolean, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/RoomEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/RoomEntityHelper.kt index 724f307e3bd..9ad2708b438 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/RoomEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/RoomEntityHelper.kt @@ -18,9 +18,16 @@ package org.matrix.android.sdk.internal.database.helper import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.RoomEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity internal fun RoomEntity.addIfNecessary(chunkEntity: ChunkEntity) { if (!chunks.contains(chunkEntity)) { chunks.add(chunkEntity) } } + +internal fun RoomEntity.addIfNecessary(threadSummary: ThreadSummaryEntity) { + if (!threadSummaries.contains(threadSummary)) { + threadSummaries.add(threadSummary) + } +} 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 f703bfaf82a..ee3008d40b1 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 @@ -34,7 +34,7 @@ import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.database.query.whereRoomId -private typealias ThreadSummary = Pair? +private typealias Summary = Pair? /** * Finds the root thread event and update it with the latest message summary along with the number @@ -93,11 +93,12 @@ internal fun EventEntity.markEventAsRoot( * @param rootThreadEventId The root eventId that will find the number of threads * @return A ThreadSummary containing the counted threads and the latest event message */ -internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: String, chunkEntity: ChunkEntity?): ThreadSummary { +internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: String, chunkEntity: ChunkEntity?): Summary { // Number of messages val messages = TimelineEventEntity .whereRoomId(realm, roomId = roomId) .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .distinct(TimelineEventEntityFields.ROOT.EVENT_ID) .count() .toInt() @@ -123,7 +124,7 @@ internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: result ?: return null - return ThreadSummary(messages, result) + return Summary(messages, result) } /** @@ -156,6 +157,7 @@ internal fun TimelineEventEntity.Companion.findAllThreadsForRoomId(realm: Realm, TimelineEventEntity .whereRoomId(realm, roomId = roomId) .equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true) + .equalTo(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, false) .sort("${TimelineEventEntityFields.ROOT.THREAD_SUMMARY_LATEST_MESSAGE}.${TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS}", Sort.DESCENDING) /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt new file mode 100644 index 00000000000..7087f071621 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt @@ -0,0 +1,328 @@ +/* + * Copyright 2022 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.database.helper + +import io.realm.Realm +import io.realm.RealmQuery +import io.realm.Sort +import io.realm.kotlin.createObject +import org.matrix.android.sdk.api.session.crypto.CryptoService +import org.matrix.android.sdk.api.session.crypto.MXCryptoError +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummaryUpdateType +import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult +import org.matrix.android.sdk.internal.database.mapper.asDomain +import org.matrix.android.sdk.internal.database.mapper.toEntity +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity +import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.EventInsertType +import org.matrix.android.sdk.internal.database.model.RoomEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields +import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore +import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.database.query.getOrNull +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent +import org.matrix.android.sdk.internal.session.room.timeline.TimelineEventDecryptor +import timber.log.Timber +import java.util.UUID + +internal fun ThreadSummaryEntity.updateThreadSummary( + rootThreadEventEntity: EventEntity, + numberOfThreads: Int?, + latestThreadEventEntity: EventEntity?, + isUserParticipating: Boolean, + roomMemberContentsByUser: HashMap) { + updateThreadSummaryRootEvent(rootThreadEventEntity, roomMemberContentsByUser) + updateThreadSummaryLatestEvent(latestThreadEventEntity, roomMemberContentsByUser) + this.isUserParticipating = isUserParticipating + numberOfThreads?.let { + // Update number of threads only when there is an actual value + this.numberOfThreads = it + } +} + +/** + * Updates the root thread event properties + */ +internal fun ThreadSummaryEntity.updateThreadSummaryRootEvent( + rootThreadEventEntity: EventEntity, + roomMemberContentsByUser: HashMap +) { + val roomId = rootThreadEventEntity.roomId + val rootThreadRoomMemberContent = roomMemberContentsByUser[rootThreadEventEntity.sender ?: ""] + this.rootThreadEventEntity = rootThreadEventEntity + this.rootThreadSenderAvatar = rootThreadRoomMemberContent?.avatarUrl + this.rootThreadSenderName = rootThreadRoomMemberContent?.displayName + this.rootThreadIsUniqueDisplayName = if (rootThreadRoomMemberContent?.displayName != null) { + computeIsUnique(realm, roomId, false, rootThreadRoomMemberContent, roomMemberContentsByUser) + } else { + true + } +} + +/** + * Updates the latest thread event properties + */ +internal fun ThreadSummaryEntity.updateThreadSummaryLatestEvent( + latestThreadEventEntity: EventEntity?, + roomMemberContentsByUser: HashMap +) { + val roomId = latestThreadEventEntity?.roomId ?: return + val latestThreadRoomMemberContent = roomMemberContentsByUser[latestThreadEventEntity.sender ?: ""] + this.latestThreadEventEntity = latestThreadEventEntity + this.latestThreadSenderAvatar = latestThreadRoomMemberContent?.avatarUrl + this.latestThreadSenderName = latestThreadRoomMemberContent?.displayName + this.latestThreadIsUniqueDisplayName = if (latestThreadRoomMemberContent?.displayName != null) { + computeIsUnique(realm, roomId, false, latestThreadRoomMemberContent, roomMemberContentsByUser) + } else { + true + } +} + +private fun EventEntity.toTimelineEventEntity(roomMemberContentsByUser: HashMap): TimelineEventEntity { + val roomId = roomId + val eventId = eventId + val localId = TimelineEventEntity.nextId(realm) + val senderId = sender ?: "" + + val timelineEventEntity = realm.createObject().apply { + this.localId = localId + this.root = this@toTimelineEventEntity + this.eventId = eventId + this.roomId = roomId + this.annotations = EventAnnotationsSummaryEntity.where(realm, roomId, eventId).findFirst() + ?.also { it.cleanUp(sender) } + this.ownedByThreadChunk = true // To skip it from the original event flow + val roomMemberContent = roomMemberContentsByUser[senderId] + this.senderAvatar = roomMemberContent?.avatarUrl + this.senderName = roomMemberContent?.displayName + isUniqueDisplayName = if (roomMemberContent?.displayName != null) { + computeIsUnique(realm, roomId, false, roomMemberContent, roomMemberContentsByUser) + } else { + true + } + } + return timelineEventEntity +} + +internal suspend fun ThreadSummaryEntity.Companion.createOrUpdate( + threadSummaryType: ThreadSummaryUpdateType, + realm: Realm, + roomId: String, + threadEventEntity: EventEntity? = null, + rootThreadEvent: Event? = null, + roomMemberContentsByUser: HashMap, + roomEntity: RoomEntity, + userId: String, + cryptoService: CryptoService? = null +) { + when (threadSummaryType) { + ThreadSummaryUpdateType.REPLACE -> { + rootThreadEvent?.eventId ?: return + rootThreadEvent.senderId ?: return + + val numberOfThreads = rootThreadEvent.unsignedData?.relations?.latestThread?.count ?: return + + // Something is wrong with the server return + if (numberOfThreads <= 0) return + + val threadSummary = ThreadSummaryEntity.getOrCreate(realm, roomId, rootThreadEvent.eventId).also { + Timber.i("###THREADS ThreadSummaryHelper REPLACE eventId:${it.rootThreadEventId} ") + } + + val rootThreadEventEntity = createEventEntity(roomId, rootThreadEvent, realm).also { + decryptIfNeeded(cryptoService, it, roomId) + } + val latestThreadEventEntity = createLatestEventEntity(roomId, rootThreadEvent, roomMemberContentsByUser, realm)?.also { + decryptIfNeeded(cryptoService, it, roomId) + } + val isUserParticipating = rootThreadEvent.unsignedData.relations.latestThread.isUserParticipating == true || rootThreadEvent.senderId == userId + roomMemberContentsByUser.addSenderState(realm, roomId, rootThreadEvent.senderId) + threadSummary.updateThreadSummary( + rootThreadEventEntity = rootThreadEventEntity, + numberOfThreads = numberOfThreads, + latestThreadEventEntity = latestThreadEventEntity, + isUserParticipating = isUserParticipating, + roomMemberContentsByUser = roomMemberContentsByUser + ) + + roomEntity.addIfNecessary(threadSummary) + } + ThreadSummaryUpdateType.ADD -> { + val rootThreadEventId = threadEventEntity?.rootThreadEventId ?: return + Timber.i("###THREADS ThreadSummaryHelper ADD for root eventId:$rootThreadEventId") + + val threadSummary = ThreadSummaryEntity.getOrNull(realm, roomId, rootThreadEventId) + if (threadSummary != null) { + // ThreadSummary exists so lets add the latest event + Timber.i("###THREADS ThreadSummaryHelper ADD root eventId:$rootThreadEventId exists, lets update latest thread event.") + threadSummary.updateThreadSummaryLatestEvent(threadEventEntity, roomMemberContentsByUser) + threadSummary.numberOfThreads++ + if (threadEventEntity.sender == userId) { + threadSummary.isUserParticipating = true + } + } else { + // ThreadSummary do not exists lets try to create one + Timber.i("###THREADS ThreadSummaryHelper ADD root eventId:$rootThreadEventId do not exists, lets try to create one") + threadEventEntity.findRootThreadEvent()?.let { rootThreadEventEntity -> + // Root thread event entity exists so lets create a new record + ThreadSummaryEntity.getOrCreate(realm, roomId, rootThreadEventEntity.eventId).let { + it.updateThreadSummary( + rootThreadEventEntity = rootThreadEventEntity, + numberOfThreads = 1, + latestThreadEventEntity = threadEventEntity, + isUserParticipating = threadEventEntity.sender == userId, + roomMemberContentsByUser = roomMemberContentsByUser + ) + roomEntity.addIfNecessary(it) + } + } + } + } + } +} + +private suspend fun decryptIfNeeded(cryptoService: CryptoService?, eventEntity: EventEntity, roomId: String) { + cryptoService ?: return + val event = eventEntity.asDomain() + if (event.isEncrypted() && event.mxDecryptionResult == null && event.eventId != null) { + try { + Timber.i("###THREADS ThreadSummaryHelper request decryption for eventId:${event.eventId}") + // Event from sync does not have roomId, so add it to the event first + val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "") + event.mxDecryptionResult = OlmDecryptionResult( + payload = result.clearEvent, + senderKey = result.senderCurve25519Key, + keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) }, + forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain + ) + // Save decryption result, to not decrypt every time we enter the thread list + eventEntity.setDecryptionResult(result) + } catch (e: MXCryptoError) { + if (e is MXCryptoError.Base) { + event.mCryptoError = e.errorType + event.mCryptoErrorReason = e.technicalMessage.takeIf { it.isNotEmpty() } ?: e.detailedErrorDescription + } + } + } +} + +/** + * Request decryption + */ +private fun requestDecryption(eventDecryptor: TimelineEventDecryptor?, event: Event?) { + eventDecryptor ?: return + event ?: return + if (event.isEncrypted() && + event.mxDecryptionResult == null && event.eventId != null) { + Timber.i("###THREADS ThreadSummaryHelper request decryption for eventId:${event.eventId}") + + eventDecryptor.requestDecryption(TimelineEventDecryptor.DecryptionRequest(event, UUID.randomUUID().toString())) + } +} + +/** + * If we don't have any new state on this user, get it from db + */ +private fun HashMap.addSenderState(realm: Realm, roomId: String, senderId: String) { + getOrPut(senderId) { + CurrentStateEventEntity + .getOrNull(realm, roomId, senderId, EventType.STATE_ROOM_MEMBER) + ?.root?.asDomain() + ?.getFixedRoomMemberContent() + } +} + +/** + * Create an EventEntity for the root thread event or get an existing one + */ +private fun createEventEntity(roomId: String, event: Event, realm: Realm): EventEntity { + val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it } + return event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) +} + +/** + * Create an EventEntity for the latest thread event or get an existing one. Also update the user room member + * state + */ +private fun createLatestEventEntity( + roomId: String, + rootThreadEvent: Event, + roomMemberContentsByUser: HashMap, + realm: Realm): EventEntity? { + return getLatestEvent(rootThreadEvent)?.let { + it.senderId?.let { senderId -> + roomMemberContentsByUser.addSenderState(realm, roomId, senderId) + } + createEventEntity(roomId, it, realm) + } +} + +/** + * Returned the latest event message, if any + */ +private fun getLatestEvent(rootThreadEvent: Event): Event? { + return rootThreadEvent.unsignedData?.relations?.latestThread?.event +} + +/** + * Find all ThreadSummaryEntity for the specified roomId, sorted by origin server + * note: Sorting cannot be provided by server, so we have to use that unstable property + * @param roomId The id of the room + */ +internal fun ThreadSummaryEntity.Companion.findAllThreadsForRoomId(realm: Realm, roomId: String): RealmQuery = + ThreadSummaryEntity + .where(realm, roomId = roomId) + .sort(ThreadSummaryEntityFields.LATEST_THREAD_EVENT_ENTITY.ORIGIN_SERVER_TS, Sort.DESCENDING) + +/** + * Enhance each [ThreadSummary] root and latest event with the equivalent decrypted text edition/replacement + */ +internal fun List.enhanceWithEditions(realm: Realm, roomId: String): List = + this.map { + it.addEditionIfNeeded(realm, roomId, true) + it.addEditionIfNeeded(realm, roomId, false) + it + } + +private fun ThreadSummary.addEditionIfNeeded(realm: Realm, roomId: String, enhanceRoot: Boolean) { + val eventId = if (enhanceRoot) rootEventId else latestEvent?.eventId ?: return + EventAnnotationsSummaryEntity + .where(realm, roomId, eventId) + .findFirst() + ?.editSummary + ?.editions + ?.lastOrNull() + ?.eventId + ?.let { editedEventId -> + TimelineEventEntity.where(realm, roomId, eventId = editedEventId).findFirst()?.let { editedEvent -> + if (enhanceRoot) { + threadEditions.rootThreadEdition = editedEvent.root?.asDomain()?.getDecryptedTextSummary() ?: "(edited)" + } else { + threadEditions.latestThreadEdition = editedEvent.root?.asDomain()?.getDecryptedTextSummary() ?: "(edited)" + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt index 9c420e81fd7..c3302f5ccbe 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt @@ -114,7 +114,7 @@ internal object EventMapper { ) }, threadNotificationState = eventEntity.threadNotificationState, - threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedTextSummary(), + threadSummaryLatestEvent = eventEntity.threadSummaryLatestMessage?.root?.asDomain(), lastMessageTimestamp = eventEntity.threadSummaryLatestMessage?.root?.originServerTs ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt index 7869506015e..2e33988a22b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt @@ -41,7 +41,8 @@ internal object HomeServerCapabilitiesMapper { maxUploadFileSize = entity.maxUploadFileSize, lastVersionIdentityServerSupported = entity.lastVersionIdentityServerSupported, defaultIdentityServerUrl = entity.defaultIdentityServerUrl, - roomVersions = mapRoomVersion(entity.roomVersionsJson) + roomVersions = mapRoomVersion(entity.roomVersionsJson), + canUseThreading = entity.canUseThreading ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ThreadSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ThreadSummaryMapper.kt new file mode 100644 index 00000000000..cedb9e3d452 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ThreadSummaryMapper.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2022 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.database.mapper + +import org.matrix.android.sdk.api.session.room.sender.SenderInfo +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +import javax.inject.Inject + +internal class ThreadSummaryMapper @Inject constructor() { + + fun map(threadSummary: ThreadSummaryEntity): ThreadSummary { + return ThreadSummary( + roomId = threadSummary.room?.firstOrNull()?.roomId.orEmpty(), + rootEvent = threadSummary.rootThreadEventEntity?.asDomain(), + latestEvent = threadSummary.latestThreadEventEntity?.asDomain(), + rootEventId = threadSummary.rootThreadEventId.orEmpty(), + rootThreadSenderInfo = SenderInfo( + userId = threadSummary.rootThreadEventEntity?.sender ?: "", + displayName = threadSummary.rootThreadSenderName, + isUniqueDisplayName = threadSummary.rootThreadIsUniqueDisplayName, + avatarUrl = threadSummary.rootThreadSenderAvatar + ), + latestThreadSenderInfo = SenderInfo( + userId = threadSummary.latestThreadEventEntity?.sender ?: "", + displayName = threadSummary.latestThreadSenderName, + isUniqueDisplayName = threadSummary.latestThreadIsUniqueDisplayName, + avatarUrl = threadSummary.latestThreadSenderAvatar + ), + isUserParticipating = threadSummary.isUserParticipating, + numberOfThreads = threadSummary.numberOfThreads + ) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt index 55c7f2a8ee6..e6586224444 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/TimelineEventMapper.kt @@ -46,6 +46,7 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName, avatarUrl = timelineEventEntity.senderAvatar ), + ownedByThreadChunk = timelineEventEntity.ownedByThreadChunk, readReceipts = readReceipts ?.distinctBy { it.roomMember diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo026.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo026.kt new file mode 100644 index 00000000000..f108a91ecf0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo026.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 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.database.migration + +import io.realm.DynamicRealm +import io.realm.FieldAttribute +import org.matrix.android.sdk.internal.database.model.ChunkEntityFields +import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields +import org.matrix.android.sdk.internal.database.model.RoomEntityFields +import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields +import org.matrix.android.sdk.internal.extensions.forceRefreshOfHomeServerCapabilities +import org.matrix.android.sdk.internal.util.database.RealmMigrator + +/** + * Migrating to: + * Live thread list: using enhanced /messages api MSC3440 + * Live thread timeline: using /relations api + */ +class MigrateSessionTo026(realm: DynamicRealm) : RealmMigrator(realm, 26) { + + override fun doMigrate(realm: DynamicRealm) { + realm.schema.get("ChunkEntity") + ?.addField(ChunkEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED) + ?.addField(ChunkEntityFields.IS_LAST_FORWARD_THREAD, Boolean::class.java, FieldAttribute.INDEXED) + + realm.schema.get("TimelineEventEntity") + ?.addField(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, Boolean::class.java) + + val eventEntity = realm.schema.get("EventEntity") ?: return + val threadSummaryEntity = realm.schema.create("ThreadSummaryEntity") + .addField(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED) + .addField(ThreadSummaryEntityFields.ROOT_THREAD_SENDER_NAME, String::class.java) + .addField(ThreadSummaryEntityFields.ROOT_THREAD_SENDER_AVATAR, String::class.java) + .addField(ThreadSummaryEntityFields.ROOT_THREAD_IS_UNIQUE_DISPLAY_NAME, Boolean::class.java) + .addField(ThreadSummaryEntityFields.LATEST_THREAD_SENDER_NAME, String::class.java) + .addField(ThreadSummaryEntityFields.LATEST_THREAD_SENDER_AVATAR, String::class.java) + .addField(ThreadSummaryEntityFields.LATEST_THREAD_IS_UNIQUE_DISPLAY_NAME, Boolean::class.java) + .addField(ThreadSummaryEntityFields.NUMBER_OF_THREADS, Int::class.java) + .addField(ThreadSummaryEntityFields.IS_USER_PARTICIPATING, Boolean::class.java) + .addRealmObjectField(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ENTITY.`$`, eventEntity) + .addRealmObjectField(ThreadSummaryEntityFields.LATEST_THREAD_EVENT_ENTITY.`$`, eventEntity) + + realm.schema.get("RoomEntity") + ?.addRealmListField(RoomEntityFields.THREAD_SUMMARIES.`$`, threadSummaryEntity) + + realm.schema.get("HomeServerCapabilitiesEntity") + ?.addField(HomeServerCapabilitiesEntityFields.CAN_USE_THREADING, Boolean::class.java) + ?.forceRefreshOfHomeServerCapabilities() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt index c45c27ed08a..88eb821aa9d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ChunkEntity.kt @@ -33,7 +33,10 @@ internal open class ChunkEntity(@Index var prevToken: String? = null, var timelineEvents: RealmList = RealmList(), // Only one chunk will have isLastForward == true @Index var isLastForward: Boolean = false, - @Index var isLastBackward: Boolean = false + @Index var isLastBackward: Boolean = false, + // Threads + @Index var rootThreadEventId: String? = null, + @Index var isLastForwardThread: Boolean = false, ) : RealmObject() { fun identifier() = "${prevToken}_$nextToken" @@ -47,14 +50,32 @@ internal open class ChunkEntity(@Index var prevToken: String? = null, companion object } -internal fun ChunkEntity.deleteOnCascade(deleteStateEvents: Boolean, canDeleteRoot: Boolean) { +internal fun ChunkEntity.deleteOnCascade( + deleteStateEvents: Boolean, + canDeleteRoot: Boolean) { assertIsManaged() if (deleteStateEvents) { stateEvents.deleteAllFromRealm() } timelineEvents.clearWith { val deleteRoot = canDeleteRoot && (it.root?.stateKey == null || deleteStateEvents) + if (deleteRoot) { + room?.firstOrNull()?.removeThreadSummaryIfNeeded(it.eventId) + } it.deleteOnCascade(deleteRoot) } deleteFromRealm() } + +/** + * Delete the chunk along with the thread events that were temporarily created + */ +internal fun ChunkEntity.deleteAndClearThreadEvents() { + assertIsManaged() + timelineEvents + .filter { it.ownedByThreadChunk } + .forEach { + it.deleteOnCascade(false) + } + deleteFromRealm() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt index 445181e5764..b7158ba9cd8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt @@ -34,14 +34,14 @@ internal open class EventEntity(@Index var eventId: String = "", @Index var stateKey: String? = null, var originServerTs: Long? = null, @Index var sender: String? = null, - // Can contain a serialized MatrixError + // Can contain a serialized MatrixError var sendStateDetails: String? = null, var age: Long? = 0, var unsignedData: String? = null, var redacts: String? = null, var decryptionResultJson: String? = null, var ageLocalTs: Long? = null, - // Thread related, no need to create a new Entity for performance + // Thread related, no need to create a new Entity for performance @Index var isRootThread: Boolean = false, @Index var rootThreadEventId: String? = null, var numberOfThreads: Int = 0, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt index 08ecd5995ec..47a83f0ed99 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt @@ -28,7 +28,8 @@ internal open class HomeServerCapabilitiesEntity( var maxUploadFileSize: Long = HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN, var lastVersionIdentityServerSupported: Boolean = false, var defaultIdentityServerUrl: String? = null, - var lastUpdatedTimestamp: Long = 0L + var lastUpdatedTimestamp: Long = 0L, + var canUseThreading: Boolean = false ) : RealmObject() { companion object diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt index 2997d5d7d8a..4a6f6a7bf89 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/RoomEntity.kt @@ -20,10 +20,14 @@ import io.realm.RealmList import io.realm.RealmObject import io.realm.annotations.PrimaryKey import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +import org.matrix.android.sdk.internal.database.query.findRootOrLatest +import org.matrix.android.sdk.internal.extensions.assertIsManaged internal open class RoomEntity(@PrimaryKey var roomId: String = "", var chunks: RealmList = RealmList(), var sendingTimelineEvents: RealmList = RealmList(), + var threadSummaries: RealmList = RealmList(), var accountData: RealmList = RealmList() ) : RealmObject() { @@ -46,3 +50,10 @@ internal open class RoomEntity(@PrimaryKey var roomId: String = "", } companion object } +internal fun RoomEntity.removeThreadSummaryIfNeeded(eventId: String) { + assertIsManaged() + threadSummaries.findRootOrLatest(eventId)?.let { + threadSummaries.remove(it) + it.deleteFromRealm() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt index c0907779721..d0d23dd491b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.database.model import io.realm.annotations.RealmModule import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity /** * Realm module for Session @@ -66,6 +67,7 @@ import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntit RoomAccountDataEntity::class, SpaceChildSummaryEntity::class, SpaceParentSummaryEntity::class, - UserPresenceEntity::class + UserPresenceEntity::class, + ThreadSummaryEntity::class ]) internal class SessionRealmModule 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 185f0e2dcc4..aacd6570bc4 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 @@ -32,6 +32,9 @@ internal open class TimelineEventEntity(var localId: Long = 0, var isUniqueDisplayName: Boolean = false, var senderAvatar: String? = null, var senderMembershipEventId: String? = null, + // ownedByThreadChunk indicates that the current TimelineEventEntity belongs + // to a thread chunk and is a temporarily event. + var ownedByThreadChunk: Boolean = false, var readReceipts: ReadReceiptsSummaryEntity? = null ) : RealmObject() { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt new file mode 100644 index 00000000000..ab9d66548ed --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2022 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.database.model.threads + +import io.realm.RealmObject +import io.realm.RealmResults +import io.realm.annotations.Index +import io.realm.annotations.LinkingObjects +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.RoomEntity + +internal open class ThreadSummaryEntity(@Index var rootThreadEventId: String? = "", + var rootThreadEventEntity: EventEntity? = null, + var latestThreadEventEntity: EventEntity? = null, + var rootThreadSenderName: String? = null, + var latestThreadSenderName: String? = null, + var rootThreadSenderAvatar: String? = null, + var latestThreadSenderAvatar: String? = null, + var rootThreadIsUniqueDisplayName: Boolean = false, + var isUserParticipating: Boolean = false, + var latestThreadIsUniqueDisplayName: Boolean = false, + var numberOfThreads: Int = 0 +) : RealmObject() { + + @LinkingObjects("threadSummaries") + val room: RealmResults? = null + + companion object +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt index 156a8dd767c..ece46555a7e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ChunkEntityQueries.kt @@ -45,10 +45,22 @@ internal fun ChunkEntity.Companion.findLastForwardChunkOfRoom(realm: Realm, room .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) .findFirst() } - +internal fun ChunkEntity.Companion.findLastForwardChunkOfThread(realm: Realm, roomId: String, rootThreadEventId: String): ChunkEntity? { + return where(realm, roomId) + .equalTo(ChunkEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .equalTo(ChunkEntityFields.IS_LAST_FORWARD_THREAD, true) + .findFirst() +} +internal fun ChunkEntity.Companion.findEventInThreadChunk(realm: Realm, roomId: String, event: String): ChunkEntity? { + return where(realm, roomId) + .`in`(ChunkEntityFields.TIMELINE_EVENTS.EVENT_ID, arrayListOf(event).toTypedArray()) + .equalTo(ChunkEntityFields.IS_LAST_FORWARD_THREAD, true) + .findFirst() +} internal fun ChunkEntity.Companion.findAllIncludingEvents(realm: Realm, eventIds: List): RealmResults { return realm.where() .`in`(ChunkEntityFields.TIMELINE_EVENTS.EVENT_ID, eventIds.toTypedArray()) + .isNull(ChunkEntityFields.ROOT_THREAD_EVENT_ID) .findAll() } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventAnnotationsSummaryEntityQuery.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventAnnotationsSummaryEntityQuery.kt index 14cb7e22da2..6caa832110c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventAnnotationsSummaryEntityQuery.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventAnnotationsSummaryEntityQuery.kt @@ -34,7 +34,7 @@ internal fun EventAnnotationsSummaryEntity.Companion.create(realm: Realm, roomId this.roomId = roomId } // Denormalization - TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId).findFirst()?.let { + TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId).findAll()?.forEach { it.annotations = obj } return obj diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryEntityQueries.kt new file mode 100644 index 00000000000..517d43d7cf9 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryEntityQueries.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2022 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.database.query + +import io.realm.Realm +import io.realm.RealmList +import io.realm.RealmQuery +import io.realm.kotlin.createObject +import io.realm.kotlin.where +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields + +internal fun ThreadSummaryEntity.Companion.where(realm: Realm, roomId: String): RealmQuery { + return realm.where() + .equalTo(ThreadSummaryEntityFields.ROOM.ROOM_ID, roomId) +} + +internal fun ThreadSummaryEntity.Companion.where(realm: Realm, roomId: String, rootThreadEventId: String): RealmQuery { + return where(realm, roomId) + .equalTo(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId) +} + +internal fun ThreadSummaryEntity.Companion.getOrCreate(realm: Realm, roomId: String, rootThreadEventId: String): ThreadSummaryEntity { + return where(realm, roomId, rootThreadEventId).findFirst() ?: realm.createObject().apply { + this.rootThreadEventId = rootThreadEventId + } +} +internal fun ThreadSummaryEntity.Companion.getOrNull(realm: Realm, roomId: String, rootThreadEventId: String): ThreadSummaryEntity? { + return where(realm, roomId, rootThreadEventId).findFirst() +} +internal fun RealmList.find(rootThreadEventId: String): ThreadSummaryEntity? { + return this.where() + .equalTo(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .findFirst() +} + +internal fun RealmList.findRootOrLatest(eventId: String): ThreadSummaryEntity? { + return this.where() + .beginGroup() + .equalTo(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, eventId) + .or() + .equalTo(ThreadSummaryEntityFields.LATEST_THREAD_EVENT_ENTITY.EVENT_ID, eventId) + .endGroup() + .findFirst() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt index 7415b988a43..676a4f6a38c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt @@ -17,9 +17,21 @@ package org.matrix.android.sdk.internal.session.filter import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.RelationType +import timber.log.Timber internal object FilterFactory { + fun createThreadsFilter(numberOfEvents: Int, userId: String?): RoomEventFilter { + Timber.i("$userId") + return RoomEventFilter( + limit = numberOfEvents, +// senders = listOf(userId), +// relationSenders = userId?.let { listOf(it) }, + relationTypes = listOf(RelationType.THREAD) + ) + } + fun createUploadsFilter(numberOfEvents: Int): RoomEventFilter { return RoomEventFilter( limit = numberOfEvents, @@ -58,8 +70,8 @@ internal object FilterFactory { private fun createElementTimelineFilter(): RoomEventFilter? { return null // RoomEventFilter().apply { - // TODO Enable this for optimization - // types = listOfSupportedEventTypes.toMutableList() + // TODO Enable this for optimization + // types = listOfSupportedEventTypes.toMutableList() // } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt index f4983229670..634ea73480a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt @@ -52,12 +52,13 @@ data class RoomEventFilter( * A list of relation types which must be exist pointing to the event being filtered. * If this list is absent then no filtering is done on relation types. */ - @Json(name = "relation_types") val relationTypes: List? = null, + @Json(name = "related_by_rel_types") val relationTypes: List? = null, /** * A list of senders of relations which must exist pointing to the event being filtered. * If this list is absent then no filtering is done on relation types. */ - @Json(name = "relation_senders") val relationSenders: List? = null, + @Json(name = "related_by_senders") val relationSenders: List? = null, + /** * A list of room IDs to include. If this list is absent then all rooms are included. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt index 830a58cd128..55526b41db6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetCapabilitiesResult.kt @@ -65,7 +65,13 @@ internal data class Capabilities( * Clients should make use of this capability to determine if users need to be encouraged to upgrade their rooms. */ @Json(name = "m.room_versions") - val roomVersions: RoomVersions? = null + val roomVersions: RoomVersions? = null, + /** + * Capability to indicate if the server supports MSC3440 Threading + * True if the user can use m.thread relation, false otherwise + */ + @Json(name = "m.thread") + val threads: BooleanCapability? = null ) @JsonClass(generateAdapter = true) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt index e822cbdcdbf..44e13d971a8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt @@ -20,9 +20,11 @@ import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.api.MatrixPatterns.getDomain import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.wellknown.WellknownResult +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orTrue import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities import org.matrix.android.sdk.internal.auth.version.Versions +import org.matrix.android.sdk.internal.auth.version.doesServerSupportThreads import org.matrix.android.sdk.internal.auth.version.isLoginAndRegistrationSupportedBySdk import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntity import org.matrix.android.sdk.internal.database.query.getOrCreate @@ -121,6 +123,8 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor( homeServerCapabilitiesEntity.roomVersionsJson = capabilities?.roomVersions?.let { MoshiProvider.providesMoshi().adapter(RoomVersions::class.java).toJson(it) } + homeServerCapabilitiesEntity.canUseThreading = /* capabilities?.threads?.enabled.orFalse() || */ + getVersionResult?.doesServerSupportThreads().orFalse() } if (getMediaConfigResult != null) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt index 2d8c3e9c78e..34e859e5093 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt @@ -36,6 +36,7 @@ import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.session.room.tags.TagsService import org.matrix.android.sdk.api.session.room.threads.ThreadsService +import org.matrix.android.sdk.api.session.room.threads.local.ThreadsLocalService import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.uploads.UploadsService @@ -56,6 +57,7 @@ internal class DefaultRoom(override val roomId: String, private val roomSummaryDataSource: RoomSummaryDataSource, private val timelineService: TimelineService, private val threadsService: ThreadsService, + private val threadsLocalService: ThreadsLocalService, private val sendService: SendService, private val draftService: DraftService, private val stateService: StateService, @@ -80,6 +82,7 @@ internal class DefaultRoom(override val roomId: String, Room, TimelineService by timelineService, ThreadsService by threadsService, + ThreadsLocalService by threadsLocalService, SendService by sendService, DraftService by draftService, StateService by stateService, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt index 8159da844f7..4a43cfc22ad 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt @@ -57,6 +57,7 @@ import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryE import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryEntityFields import org.matrix.android.sdk.internal.database.model.ReferencesAggregatedSummaryEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields import org.matrix.android.sdk.internal.database.query.create import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.where @@ -114,8 +115,8 @@ internal class EventRelationsAggregationProcessor @Inject constructor( EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst() ?.let { - TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findFirst() - ?.let { tet -> tet.annotations = it } + TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll() + ?.forEach { tet -> tet.annotations = it } } } @@ -193,6 +194,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor( handleReaction(realm, event, roomId, isLocalEcho) } } + // HandleInitialAggregatedRelations should also be applied in encrypted messages with annotations +// else if (event.unsignedData?.relations?.annotations != null) { +// Timber.v("###REACTION e2e Aggregation in room $roomId for event ${event.eventId}") +// handleInitialAggregatedRelations(realm, event, roomId, event.unsignedData.relations.annotations) +// EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst() +// ?.let { +// TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll() +// ?.forEach { tet -> tet.annotations = it } +// } +// } } EventType.REDACTION -> { val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() } @@ -240,7 +251,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } // OPT OUT serer aggregation until API mature enough - private val SHOULD_HANDLE_SERVER_AGREGGATION = false + private val SHOULD_HANDLE_SERVER_AGREGGATION = false // should be true to work with e2e private fun handleReplace(realm: Realm, event: Event, @@ -332,13 +343,18 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } if (!isLocalEcho) { - val replaceEvent = TimelineEventEntity.where(realm, roomId, eventId).findFirst() + val replaceEvent = TimelineEventEntity + .where(realm, roomId, eventId) + .equalTo(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, false) + .findFirst() handleThreadSummaryEdition(editedEvent, replaceEvent, existingSummary?.editions) } } /** * Check if the edition is on the latest thread event, and update it accordingly + * @param editedEvent The event that will be changed + * @param replaceEvent The new event */ private fun handleThreadSummaryEdition(editedEvent: EventEntity?, replaceEvent: TimelineEventEntity?, 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 399bfbd0e45..10f75473b71 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 @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomStrippedState import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams @@ -86,7 +87,7 @@ internal interface RoomAPI { suspend fun getRoomMessagesFrom(@Path("roomId") roomId: String, @Query("from") from: String, @Query("dir") dir: String, - @Query("limit") limit: Int, + @Query("limit") limit: Int?, @Query("filter") filter: String? ): PaginationResponse @@ -218,7 +219,6 @@ internal interface RoomAPI { /** * Paginate relations for event based in normal topological order - * * @param relationType filter for this relation type * @param eventType filter for this event type */ @@ -227,9 +227,24 @@ internal interface RoomAPI { @Path("eventId") eventId: String, @Path("relationType") relationType: String, @Path("eventType") eventType: String, + @Query("from") from: String? = null, + @Query("to") to: String? = null, @Query("limit") limit: Int? = null ): RelationsResponse + /** + * Paginate relations for thread events based in normal topological order + * @param relationType filter for this relation type + */ + @GET(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "rooms/{roomId}/relations/{eventId}/{relationType}") + suspend fun getThreadsRelations(@Path("roomId") roomId: String, + @Path("eventId") eventId: String, + @Path("relationType") relationType: String = RelationType.THREAD, + @Query("from") from: String? = null, + @Query("to") to: String? = null, + @Query("limit") limit: Int? = null + ): RelationsResponse + /** * Join the given room. * diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt index 70c1ab4f424..72a3f9ab220 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt @@ -36,6 +36,7 @@ import org.matrix.android.sdk.internal.session.room.state.SendStateTask import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource import org.matrix.android.sdk.internal.session.room.tags.DefaultTagsService import org.matrix.android.sdk.internal.session.room.threads.DefaultThreadsService +import org.matrix.android.sdk.internal.session.room.threads.local.DefaultThreadsLocalService import org.matrix.android.sdk.internal.session.room.timeline.DefaultTimelineService import org.matrix.android.sdk.internal.session.room.typing.DefaultTypingService import org.matrix.android.sdk.internal.session.room.uploads.DefaultUploadsService @@ -52,6 +53,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService: private val roomSummaryDataSource: RoomSummaryDataSource, private val timelineServiceFactory: DefaultTimelineService.Factory, private val threadsServiceFactory: DefaultThreadsService.Factory, + private val threadsLocalServiceFactory: DefaultThreadsLocalService.Factory, private val sendServiceFactory: DefaultSendService.Factory, private val draftServiceFactory: DefaultDraftService.Factory, private val stateServiceFactory: DefaultStateService.Factory, @@ -79,6 +81,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService: roomSummaryDataSource = roomSummaryDataSource, timelineService = timelineServiceFactory.create(roomId), threadsService = threadsServiceFactory.create(roomId), + threadsLocalService = threadsLocalServiceFactory.create(roomId), sendService = sendServiceFactory.create(roomId), draftService = draftServiceFactory.create(roomId), stateService = stateServiceFactory.create(roomId), diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt index f831a77a5d7..5e90076b8af 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt @@ -77,7 +77,9 @@ import org.matrix.android.sdk.internal.session.room.relation.DefaultUpdateQuickR import org.matrix.android.sdk.internal.session.room.relation.FetchEditHistoryTask import org.matrix.android.sdk.internal.session.room.relation.FindReactionEventForUndoTask import org.matrix.android.sdk.internal.session.room.relation.UpdateQuickReactionTask +import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadSummariesTask import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadTimelineTask +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadSummariesTask import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportContentTask import org.matrix.android.sdk.internal.session.room.reporting.ReportContentTask @@ -294,4 +296,7 @@ internal abstract class RoomModule { @Binds abstract fun bindFetchThreadTimelineTask(task: DefaultFetchThreadTimelineTask): FetchThreadTimelineTask + + @Binds + abstract fun bindFetchThreadSummariesTask(task: DefaultFetchThreadSummariesTask): FetchThreadSummariesTask } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt index d5019aea7b8..ab514d31c84 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt @@ -34,7 +34,6 @@ import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.SessionDatabase -import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor import org.matrix.android.sdk.internal.session.room.timeline.TimelineEventDataSource @@ -48,7 +47,6 @@ internal class DefaultRelationService @AssistedInject constructor( private val eventFactory: LocalEchoEventFactory, private val findReactionEventForUndoTask: FindReactionEventForUndoTask, private val fetchEditHistoryTask: FetchEditHistoryTask, - private val fetchThreadTimelineTask: FetchThreadTimelineTask, private val timelineEventDataSource: TimelineEventDataSource, @SessionDatabase private val monarchy: Monarchy ) : RelationService { @@ -196,10 +194,6 @@ internal class DefaultRelationService @AssistedInject constructor( return eventSenderProcessor.postEvent(event) } - override suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean { - return fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params(roomId, rootThreadEventId)) - } - /** * Saves the event in database as a local echo. * SendState is set to UNSENT and it's added to a the sendingTimelineEvents list of the room. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt new file mode 100644 index 00000000000..d316eed691b --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt @@ -0,0 +1,108 @@ +/* + * Copyright 2022 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.relation.threads + +import com.zhuinden.monarchy.Monarchy +import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummaryUpdateType +import org.matrix.android.sdk.internal.crypto.DefaultCryptoService +import org.matrix.android.sdk.internal.database.helper.createOrUpdate +import org.matrix.android.sdk.internal.database.model.RoomEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +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.network.GlobalErrorReceiver +import org.matrix.android.sdk.internal.network.executeRequest +import org.matrix.android.sdk.internal.session.filter.FilterFactory +import org.matrix.android.sdk.internal.session.room.RoomAPI +import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection +import org.matrix.android.sdk.internal.session.room.timeline.PaginationResponse +import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.awaitTransaction +import timber.log.Timber +import javax.inject.Inject + +/*** + * This class is responsible to Fetch all the thread in the current room, + * To fetch all threads in a room, the /messages API is used with newly added filtering options. + */ +internal interface FetchThreadSummariesTask : Task { + data class Params( + val roomId: String, + val from: String = "", + val limit: Int = 100, + val isUserParticipating: Boolean = true + ) +} + +internal class DefaultFetchThreadSummariesTask @Inject constructor( + private val roomAPI: RoomAPI, + private val globalErrorReceiver: GlobalErrorReceiver, + @SessionDatabase private val monarchy: Monarchy, + private val cryptoService: DefaultCryptoService, + @UserId private val userId: String, +) : FetchThreadSummariesTask { + + override suspend fun execute(params: FetchThreadSummariesTask.Params): Result { + val filter = FilterFactory.createThreadsFilter( + numberOfEvents = params.limit, + userId = if (params.isUserParticipating) userId else null).toJSONString() + + val response = executeRequest( + globalErrorReceiver, + canRetry = true + ) { + roomAPI.getRoomMessagesFrom(params.roomId, params.from, PaginationDirection.BACKWARDS.value, params.limit, filter) + } + + Timber.i("###THREADS DefaultFetchThreadSummariesTask Fetched size:${response.events.size} nextBatch:${response.end} ") + + return handleResponse(response, params) + } + + private suspend fun handleResponse(response: PaginationResponse, + params: FetchThreadSummariesTask.Params): Result { + val rootThreadList = response.events + monarchy.awaitTransaction { realm -> + val roomEntity = RoomEntity.where(realm, roomId = params.roomId).findFirst() ?: return@awaitTransaction + + val roomMemberContentsByUser = HashMap() + for (rootThreadEvent in rootThreadList) { + if (rootThreadEvent.eventId == null || rootThreadEvent.senderId == null || rootThreadEvent.type == null) { + continue + } + + ThreadSummaryEntity.createOrUpdate( + threadSummaryType = ThreadSummaryUpdateType.REPLACE, + realm = realm, + roomId = params.roomId, + rootThreadEvent = rootThreadEvent, + roomMemberContentsByUser = roomMemberContentsByUser, + roomEntity = roomEntity, + userId = userId, + cryptoService = cryptoService) + } + } + return Result.SUCCESS + } + + enum class Result { + SHOULD_FETCH_MORE, + REACHED_END, + SUCCESS + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt index cd06d47f05e..a46bbe8d9f8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Matrix.org Foundation C.I.C. + * Copyright 2022 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. @@ -20,14 +20,12 @@ import io.realm.Realm import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType -import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider import org.matrix.android.sdk.internal.crypto.DefaultCryptoService import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.database.helper.addTimelineEvent -import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.database.model.ChunkEntity @@ -36,8 +34,10 @@ import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEnt import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore -import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom +import org.matrix.android.sdk.internal.database.query.find +import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfThread import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.getOrNull import org.matrix.android.sdk.internal.database.query.where @@ -47,16 +47,38 @@ import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent import org.matrix.android.sdk.internal.session.room.RoomAPI +import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.util.awaitTransaction import timber.log.Timber import javax.inject.Inject -internal interface FetchThreadTimelineTask : Task { +/*** + * This class is responsible to Fetch paginated chunks of the thread timeline using the /relations API + * + * How it works + * + * The problem? + * - We cannot use the existing timeline architecture to paginate through the timeline + * - We want our new events to be live, so any interactions with them like reactions will continue to work. We should + * handle appropriately the existing events from /messages api with the new events from /relations. + * - Handling edge cases like receiving an event from /messages while you have already created a new one from the /relations response + * + * The solution + * We generate a temporarily thread chunk that will be used to store any new paginated results from the /relations api + * We bind the timeline events from that chunk with the already existing ones. So we will have one common instance, and + * all reactions, edits etc will continue to work. If the events do not exists we create them + * and we will reuse the same EventEntity instance when (and if) the same event will be fetched from the main (/messages) timeline + * + */ +internal interface FetchThreadTimelineTask : Task { data class Params( val roomId: String, - val rootThreadEventId: String + val rootThreadEventId: String, + val from: String?, + val limit: Int + ) } @@ -69,93 +91,129 @@ internal class DefaultFetchThreadTimelineTask @Inject constructor( private val cryptoService: DefaultCryptoService ) : FetchThreadTimelineTask { - override suspend fun execute(params: FetchThreadTimelineTask.Params): Boolean { - val isRoomEncrypted = cryptoSessionInfoProvider.isRoomEncrypted(params.roomId) + enum class Result { + SHOULD_FETCH_MORE, + REACHED_END, + SUCCESS + } + + override suspend fun execute(params: FetchThreadTimelineTask.Params): Result { val response = executeRequest(globalErrorReceiver) { - roomAPI.getRelations( + roomAPI.getThreadsRelations( roomId = params.roomId, eventId = params.rootThreadEventId, - relationType = RelationType.IO_THREAD, - eventType = if (isRoomEncrypted) EventType.ENCRYPTED else EventType.MESSAGE, - limit = 2000 + from = params.from, + limit = params.limit ) } - val threadList = response.chunks + listOfNotNull(response.originalEvent) - - return storeNewEventsIfNeeded(threadList, params.roomId) + Timber.i("###THREADS FetchThreadTimelineTask Fetched size:${response.chunks.size} nextBatch:${response.nextBatch} ") + return handleRelationsResponse(response, params) } - /** - * Store new events if they are not already received, and returns weather or not, - * a timeline update should be made - * @param threadList is the list containing the thread replies - * @param roomId the roomId of the the thread - * @return - */ - private suspend fun storeNewEventsIfNeeded(threadList: List, roomId: String): Boolean { - var eventsSkipped = 0 - monarchy - .awaitTransaction { realm -> - val chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) - - val optimizedThreadSummaryMap = hashMapOf() - val roomMemberContentsByUser = HashMap() - - for (event in threadList.reversed()) { - if (event.eventId == null || event.senderId == null || event.type == null) { - eventsSkipped++ - continue - } - - if (EventEntity.where(realm, event.eventId).findFirst() != null) { - // Skip if event already exists - eventsSkipped++ - continue - } - if (event.isEncrypted()) { - // Decrypt events that will be stored - decryptIfNeeded(event, roomId) - } - - handleReaction(realm, event, roomId) - - val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it } - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.INCREMENTAL_SYNC) - - // Sender info - roomMemberContentsByUser.getOrPut(event.senderId) { - // If we don't have any new state on this user, get it from db - val rootStateEvent = CurrentStateEventEntity.getOrNull(realm, roomId, event.senderId, EventType.STATE_ROOM_MEMBER)?.root - rootStateEvent?.asDomain()?.getFixedRoomMemberContent() - } - - chunk?.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser) - eventEntity.rootThreadEventId?.let { - // This is a thread event - optimizedThreadSummaryMap[it] = eventEntity - } ?: run { - // This is a normal event or a root thread one - optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity - } + private suspend fun handleRelationsResponse(response: RelationsResponse, + params: FetchThreadTimelineTask.Params): Result { + val threadList = response.chunks + val threadRootEvent = response.originalEvent + val hasReachEnd = response.nextBatch == null + + monarchy.awaitTransaction { realm -> + + val threadChunk = ChunkEntity.findLastForwardChunkOfThread(realm, params.roomId, params.rootThreadEventId) + ?: run { + return@awaitTransaction } - optimizedThreadSummaryMap.updateThreadSummaryIfNeeded( - roomId = roomId, - realm = realm, - currentUserId = userId, - shouldUpdateNotifications = false - ) + threadChunk.prevToken = response.nextBatch + val roomMemberContentsByUser = HashMap() + + for (event in threadList) { + if (event.eventId == null || event.senderId == null || event.type == null) { + continue + } + + if (threadChunk.timelineEvents.find(event.eventId) != null) { + // Event already exists in thread chunk, skip it + Timber.i("###THREADS FetchThreadTimelineTask event: ${event.eventId} already exists in thread chunk, skip it") + continue + } + + val timelineEvent = TimelineEventEntity + .where(realm, roomId = params.roomId, event.eventId) + .findFirst() + + if (timelineEvent != null) { + // Event already exists but not in the thread chunk + // Lets added there + Timber.i("###THREADS FetchThreadTimelineTask event: ${event.eventId} exists but not in the thread chunk, add it at the end") + threadChunk.timelineEvents.add(timelineEvent) + } else { + Timber.i("###THREADS FetchThreadTimelineTask event: ${event.eventId} is brand NEW create an entity and add it!") + val eventEntity = createEventEntity(params.roomId, event, realm) + roomMemberContentsByUser.addSenderState(realm, params.roomId, event.senderId) + threadChunk.addTimelineEvent( + roomId = params.roomId, + eventEntity = eventEntity, + direction = PaginationDirection.FORWARDS, + ownedByThreadChunk = true, + roomMemberContentsByUser = roomMemberContentsByUser) } - Timber.i("----> size: ${threadList.size} | skipped: $eventsSkipped | threads: ${threadList.map { it.eventId }}") + } - return eventsSkipped == threadList.size + if (hasReachEnd) { + val rootThread = TimelineEventEntity + .where(realm, roomId = params.roomId, params.rootThreadEventId) + .findFirst() + if (rootThread != null) { + // If root thread event already exists add it to our chunk + threadChunk.timelineEvents.add(rootThread) + Timber.i("###THREADS FetchThreadTimelineTask root thread event: ${params.rootThreadEventId} found and added!") + } else if (threadRootEvent?.senderId != null) { + // Case when thread event is not in the device + Timber.i("###THREADS FetchThreadTimelineTask root thread event: ${params.rootThreadEventId} NOT FOUND! Lets create a temp one") + val eventEntity = createEventEntity(params.roomId, threadRootEvent, realm) + roomMemberContentsByUser.addSenderState(realm, params.roomId, threadRootEvent.senderId) + threadChunk.addTimelineEvent( + roomId = params.roomId, + eventEntity = eventEntity, + direction = PaginationDirection.FORWARDS, + ownedByThreadChunk = true, + roomMemberContentsByUser = roomMemberContentsByUser) + } + } + } + + return if (hasReachEnd) { + Result.REACHED_END + } else { + Result.SHOULD_FETCH_MORE + } } + // TODO Reuse this function to all the app /** - * Invoke the event decryption mechanism for a specific event + * If we don't have any new state on this user, get it from db */ + private fun HashMap.addSenderState(realm: Realm, roomId: String, senderId: String) { + getOrPut(senderId) { + CurrentStateEventEntity + .getOrNull(realm, roomId, senderId, EventType.STATE_ROOM_MEMBER) + ?.root?.asDomain() + ?.getFixedRoomMemberContent() + } + } + /** + * Create an EventEntity to be added in the TimelineEventEntity + */ + private fun createEventEntity(roomId: String, event: Event, realm: Realm): EventEntity { + val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it } + return event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) + } + + /** + * Invoke the event decryption mechanism for a specific event + */ private suspend fun decryptIfNeeded(event: Event, roomId: String) { try { // Event from sync does not have roomId, so add it to the event first diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index 8d32c536042..bec0ce97dc7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt @@ -341,8 +341,9 @@ internal class LocalEchoEventFactory @Inject constructor( url = attachment.queryUri.toString(), relatesTo = rootThreadEventId?.let { RelationDefaultContent( - type = RelationType.IO_THREAD, + type = RelationType.THREAD, eventId = it, + isFallingBack = true, inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) ) } @@ -384,8 +385,9 @@ internal class LocalEchoEventFactory @Inject constructor( url = attachment.queryUri.toString(), relatesTo = rootThreadEventId?.let { RelationDefaultContent( - type = RelationType.IO_THREAD, + type = RelationType.THREAD, eventId = it, + isFallingBack = true, inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) ) } @@ -414,8 +416,9 @@ internal class LocalEchoEventFactory @Inject constructor( voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap(), relatesTo = rootThreadEventId?.let { RelationDefaultContent( - type = RelationType.IO_THREAD, + type = RelationType.THREAD, eventId = it, + isFallingBack = true, inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) ) } @@ -434,8 +437,9 @@ internal class LocalEchoEventFactory @Inject constructor( url = attachment.queryUri.toString(), relatesTo = rootThreadEventId?.let { RelationDefaultContent( - type = RelationType.IO_THREAD, + type = RelationType.THREAD, eventId = it, + isFallingBack = true, inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) ) } @@ -467,7 +471,7 @@ internal class LocalEchoEventFactory @Inject constructor( private fun enhanceStickerIfNeeded(type: String, content: Content?): Content? { var newContent: Content? = null if (type == EventType.STICKER) { - val isThread = (content.toModel())?.relatesTo?.type == RelationType.IO_THREAD + val isThread = (content.toModel())?.relatesTo?.type == RelationType.THREAD val rootThreadEventId = (content.toModel())?.relatesTo?.eventId if (isThread && rootThreadEventId != null) { val newRelationalDefaultContent = (content.toModel())?.relatesTo?.copy( @@ -548,7 +552,7 @@ internal class LocalEchoEventFactory @Inject constructor( relatesTo = generateReplyRelationContent( eventId = eventId, rootThreadEventId = rootThreadEventId, - showAsReply = showInThread)) + showInThread = showInThread)) return createMessageEvent(roomId, content) } @@ -558,18 +562,20 @@ internal class LocalEchoEventFactory @Inject constructor( * "m.relates_to": { * "rel_type": "m.thread", * "event_id": "$thread_root", + * "is_falling_back": false, * "m.in_reply_to": { - * "event_id": "$event_target", - * "render_in": ["m.thread"] - * } - * } + * "event_id": "$event_target" + * } + * } */ - private fun generateReplyRelationContent(eventId: String, rootThreadEventId: String? = null, showAsReply: Boolean): RelationDefaultContent = + private fun generateReplyRelationContent(eventId: String, rootThreadEventId: String? = null, showInThread: Boolean): RelationDefaultContent = rootThreadEventId?.let { RelationDefaultContent( - type = RelationType.IO_THREAD, + type = RelationType.THREAD, eventId = it, - inReplyTo = ReplyToContent(eventId = eventId, renderIn = if (showAsReply) arrayListOf("m.thread") else null)) + isFallingBack = showInThread, + // False when is a rich reply from within a thread, and true when is a reply that should be visible from threads + inReplyTo = ReplyToContent(eventId = eventId)) } ?: RelationDefaultContent(null, null, ReplyToContent(eventId = eventId)) private fun buildFormattedReply(permalink: String, userLink: String, userId: String, bodyFormatted: String, newBodyFormatted: String): String { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt index 5c629f87f0e..93c0167abe8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt @@ -58,8 +58,9 @@ fun TextContent.toThreadTextContent( format = MessageFormat.FORMAT_MATRIX_HTML.takeIf { formattedText != null }, body = text, relatesTo = RelationDefaultContent( - type = RelationType.IO_THREAD, + type = RelationType.THREAD, eventId = rootThreadEventId, + isFallingBack = true, inReplyTo = ReplyToContent( eventId = latestThreadEventId )), diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt index 5967ae8d2ed..b65991347dd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt @@ -23,25 +23,25 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.realm.Realm import org.matrix.android.sdk.api.session.room.threads.ThreadsService -import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.threads.ThreadNotificationState -import org.matrix.android.sdk.internal.database.helper.findAllLocalThreadNotificationsForRoomId +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary +import org.matrix.android.sdk.internal.database.helper.enhanceWithEditions import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId -import org.matrix.android.sdk.internal.database.helper.isUserParticipatingInThread -import org.matrix.android.sdk.internal.database.helper.mapEventsWithEdition +import org.matrix.android.sdk.internal.database.mapper.ThreadSummaryMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper -import org.matrix.android.sdk.internal.database.model.EventEntity -import org.matrix.android.sdk.internal.database.model.TimelineEventEntity -import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.UserId -import org.matrix.android.sdk.internal.util.awaitTransaction +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadSummariesTask +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask internal class DefaultThreadsService @AssistedInject constructor( @Assisted private val roomId: String, @UserId private val userId: String, + private val fetchThreadTimelineTask: FetchThreadTimelineTask, + private val fetchThreadSummariesTask: FetchThreadSummariesTask, @SessionDatabase private val monarchy: Monarchy, private val timelineEventMapper: TimelineEventMapper, + private val threadSummaryMapper: ThreadSummaryMapper ) : ThreadsService { @AssistedFactory @@ -49,55 +49,40 @@ internal class DefaultThreadsService @AssistedInject constructor( fun create(roomId: String): DefaultThreadsService } - override fun getMarkedThreadNotificationsLive(): LiveData> { + override fun getAllThreadSummariesLive(): LiveData> { return monarchy.findAllMappedWithChanges( - { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, - { timelineEventMapper.map(it) } + { ThreadSummaryEntity.findAllThreadsForRoomId(it, roomId = roomId) }, + { + threadSummaryMapper.map(it) + } ) } - override fun getMarkedThreadNotifications(): List { + override fun getAllThreadSummaries(): List { return monarchy.fetchAllMappedSync( - { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, - { timelineEventMapper.map(it) } + { ThreadSummaryEntity.findAllThreadsForRoomId(it, roomId = roomId) }, + { threadSummaryMapper.map(it) } ) } - override fun getAllThreadsLive(): LiveData> { - return monarchy.findAllMappedWithChanges( - { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, - { timelineEventMapper.map(it) } - ) - } - - override fun getAllThreads(): List { - return monarchy.fetchAllMappedSync( - { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, - { timelineEventMapper.map(it) } - ) - } - - override fun isUserParticipatingInThread(rootThreadEventId: String): Boolean { + override fun enhanceThreadWithEditions(threads: List): List { return Realm.getInstance(monarchy.realmConfiguration).use { - TimelineEventEntity.isUserParticipatingInThread( - realm = it, - roomId = roomId, - rootThreadEventId = rootThreadEventId, - senderId = userId) + threads.enhanceWithEditions(it, roomId) } } - override fun mapEventsWithEdition(threads: List): List { - return Realm.getInstance(monarchy.realmConfiguration).use { - threads.mapEventsWithEdition(it, roomId) - } + override suspend fun fetchThreadTimeline(rootThreadEventId: String, from: String, limit: Int) { + fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params( + roomId = roomId, + rootThreadEventId = rootThreadEventId, + from = from, + limit = limit + )) } - override suspend fun markThreadAsRead(rootThreadEventId: String) { - monarchy.awaitTransaction { - EventEntity.where( - realm = it, - eventId = rootThreadEventId).findFirst()?.threadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE - } + override suspend fun fetchThreadSummaries() { + fetchThreadSummariesTask.execute(FetchThreadSummariesTask.Params( + roomId = roomId + )) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/local/DefaultThreadsLocalService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/local/DefaultThreadsLocalService.kt new file mode 100644 index 00000000000..3bc36fb2a80 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/local/DefaultThreadsLocalService.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2022 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.threads.local + +import androidx.lifecycle.LiveData +import com.zhuinden.monarchy.Monarchy +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.realm.Realm +import org.matrix.android.sdk.api.session.room.threads.local.ThreadsLocalService +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.internal.database.helper.findAllLocalThreadNotificationsForRoomId +import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId +import org.matrix.android.sdk.internal.database.helper.isUserParticipatingInThread +import org.matrix.android.sdk.internal.database.helper.mapEventsWithEdition +import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +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.util.awaitTransaction + +internal class DefaultThreadsLocalService @AssistedInject constructor( + @Assisted private val roomId: String, + @UserId private val userId: String, + @SessionDatabase private val monarchy: Monarchy, + private val timelineEventMapper: TimelineEventMapper, +) : ThreadsLocalService { + + @AssistedFactory + interface Factory { + fun create(roomId: String): DefaultThreadsLocalService + } + + override fun getMarkedThreadNotificationsLive(): LiveData> { + return monarchy.findAllMappedWithChanges( + { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun getMarkedThreadNotifications(): List { + return monarchy.fetchAllMappedSync( + { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun getAllThreadsLive(): LiveData> { + return monarchy.findAllMappedWithChanges( + { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun getAllThreads(): List { + return monarchy.fetchAllMappedSync( + { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun isUserParticipatingInThread(rootThreadEventId: String): Boolean { + return Realm.getInstance(monarchy.realmConfiguration).use { + TimelineEventEntity.isUserParticipatingInThread( + realm = it, + roomId = roomId, + rootThreadEventId = rootThreadEventId, + senderId = userId) + } + } + + override fun mapEventsWithEdition(threads: List): List { + return Realm.getInstance(monarchy.realmConfiguration).use { + threads.mapEventsWithEdition(it, roomId) + } + } + + override suspend fun markThreadAsRead(rootThreadEventId: String) { + monarchy.awaitTransaction { + EventEntity.where( + realm = it, + eventId = rootThreadEventId).findFirst()?.threadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE + } + } +} 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 3dd4225b2c3..8c2b4d2bbe2 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 @@ -38,6 +38,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler import org.matrix.android.sdk.internal.task.SemaphoreCoroutineSequencer @@ -58,6 +59,7 @@ internal class DefaultTimeline(private val roomId: String, paginationTask: PaginationTask, getEventTask: GetContextOfEventTask, fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, + fetchThreadTimelineTask: FetchThreadTimelineTask, timelineEventMapper: TimelineEventMapper, timelineInput: TimelineInput, threadsAwarenessHandler: ThreadsAwarenessHandler, @@ -89,7 +91,9 @@ internal class DefaultTimeline(private val roomId: String, realm = backgroundRealm, eventDecryptor = eventDecryptor, paginationTask = paginationTask, + realmConfiguration = realmConfiguration, fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, + fetchThreadTimelineTask = fetchThreadTimelineTask, getContextOfEventTask = getEventTask, timelineInput = timelineInput, timelineEventMapper = timelineEventMapper, @@ -297,7 +301,13 @@ internal class DefaultTimeline(private val roomId: String, Timber.v("Post snapshot of ${snapshot.size} events") withContext(coroutineDispatchers.main) { listeners.forEach { - tryOrNull { it.onTimelineUpdated(snapshot) } + if (initialEventId != null && isFromThreadTimeline && snapshot.firstOrNull { it.eventId == initialEventId } == null) { + // We are in a thread timeline with a permalink, post update timeline only when the appropriate message have been found + tryOrNull { it.onTimelineUpdated(arrayListOf()) } + } else { + // In all the other cases update timeline as expected + tryOrNull { it.onTimelineUpdated(snapshot) } + } } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt index 8094fee504f..1ba2aff191e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt @@ -31,6 +31,7 @@ import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsS import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler @@ -42,6 +43,7 @@ internal class DefaultTimelineService @AssistedInject constructor( private val eventDecryptor: TimelineEventDecryptor, private val paginationTask: PaginationTask, private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, + private val fetchThreadTimelineTask: FetchThreadTimelineTask, private val timelineEventMapper: TimelineEventMapper, private val loadRoomMembersTask: LoadRoomMembersTask, private val threadsAwarenessHandler: ThreadsAwarenessHandler, @@ -64,10 +66,11 @@ internal class DefaultTimelineService @AssistedInject constructor( realmConfiguration = monarchy.realmConfiguration, coroutineDispatchers = coroutineDispatchers, paginationTask = paginationTask, + fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, timelineEventMapper = timelineEventMapper, timelineInput = timelineInput, eventDecryptor = eventDecryptor, - fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, + fetchThreadTimelineTask = fetchThreadTimelineTask, loadRoomMembersTask = loadRoomMembersTask, readReceiptHandler = readReceiptHandler, getEventTask = contextOfEventTask, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt index f332c4a35f6..a9e7b3bcdc4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt @@ -19,20 +19,28 @@ package org.matrix.android.sdk.internal.session.room.timeline import io.realm.OrderedCollectionChangeSet import io.realm.OrderedRealmCollectionChangeListener import io.realm.Realm +import io.realm.RealmConfiguration import io.realm.RealmResults +import io.realm.kotlin.createObject import kotlinx.coroutines.CompletableDeferred import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.internal.database.helper.addIfNecessary import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.ChunkEntityFields +import org.matrix.android.sdk.internal.database.model.RoomEntity +import org.matrix.android.sdk.internal.database.model.deleteAndClearThreadEvents import org.matrix.android.sdk.internal.database.query.findAllIncludingEvents +import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfThread import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler +import timber.log.Timber import java.util.concurrent.atomic.AtomicReference /** @@ -76,6 +84,8 @@ internal class LoadTimelineStrategy( val realm: AtomicReference, val eventDecryptor: TimelineEventDecryptor, val paginationTask: PaginationTask, + val realmConfiguration: RealmConfiguration, + val fetchThreadTimelineTask: FetchThreadTimelineTask, val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, val getContextOfEventTask: GetContextOfEventTask, val timelineInput: TimelineInput, @@ -90,7 +100,6 @@ internal class LoadTimelineStrategy( private var getContextLatch: CompletableDeferred? = null private var chunkEntity: RealmResults? = null private var timelineChunk: TimelineChunk? = null - private val chunkEntityListener = OrderedRealmCollectionChangeListener { _: RealmResults, changeSet: OrderedCollectionChangeSet -> // Can be call either when you open a permalink on an unknown event // or when there is a gap in the timeline. @@ -170,6 +179,9 @@ internal class LoadTimelineStrategy( getContextLatch?.cancel() chunkEntity = null timelineChunk = null + if (mode is Mode.Thread) { + clearThreadChunkEntity(dependencies.realm.get(), mode.rootThreadEventId) + } } suspend fun loadMore(count: Int, direction: Timeline.Direction, fetchOnServerIfNeeded: Boolean = true): LoadMoreResult { @@ -185,6 +197,9 @@ internal class LoadTimelineStrategy( return LoadMoreResult.FAILURE } } + if (mode is Mode.Thread) { + return timelineChunk?.loadMoreThread(count) ?: LoadMoreResult.FAILURE + } return timelineChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE } @@ -201,7 +216,7 @@ internal class LoadTimelineStrategy( } private fun buildSendingEvents(): List { - return if (hasReachedLastForward()) { + return if (hasReachedLastForward() || mode is Mode.Thread) { sendingEventsDataSource.buildSendingEvents() } else { emptyList() @@ -219,13 +234,47 @@ internal class LoadTimelineStrategy( ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId)) } is Mode.Thread -> { + recreateThreadChunkEntity(realm, mode.rootThreadEventId) ChunkEntity.where(realm, roomId) - .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) + .equalTo(ChunkEntityFields.ROOT_THREAD_EVENT_ID, mode.rootThreadEventId) + .equalTo(ChunkEntityFields.IS_LAST_FORWARD_THREAD, true) .findAll() } } } + /** + * Clear any existing thread chunk entity and create a new one, with the + * rootThreadEventId included + */ + private fun recreateThreadChunkEntity(realm: Realm, rootThreadEventId: String) { + realm.executeTransaction { + // Lets delete the chunk and start a new one + ChunkEntity.findLastForwardChunkOfThread(it, roomId, rootThreadEventId)?.deleteAndClearThreadEvents()?.let { + Timber.i("###THREADS LoadTimelineStrategy [onStart] thread chunk cleared..") + } + val threadChunk = it.createObject().apply { + Timber.i("###THREADS LoadTimelineStrategy [onStart] Created new thread chunk with rootThreadEventId: $rootThreadEventId") + this.rootThreadEventId = rootThreadEventId + this.isLastForwardThread = true + } + if (threadChunk.isValid) { + RoomEntity.where(it, roomId).findFirst()?.addIfNecessary(threadChunk) + } + } + } + + /** + * Clear any existing thread chunk + */ + private fun clearThreadChunkEntity(realm: Realm, rootThreadEventId: String) { + realm.executeTransaction { + ChunkEntity.findLastForwardChunkOfThread(it, roomId, rootThreadEventId)?.deleteAndClearThreadEvents()?.let { + Timber.i("###THREADS LoadTimelineStrategy [onStop] thread chunk cleared..") + } + } + } + private fun hasReachedLastForward(): Boolean { return timelineChunk?.hasReachedLastForward().orFalse() } @@ -237,8 +286,10 @@ internal class LoadTimelineStrategy( timelineSettings = dependencies.timelineSettings, roomId = roomId, timelineId = timelineId, + fetchThreadTimelineTask = dependencies.fetchThreadTimelineTask, eventDecryptor = dependencies.eventDecryptor, paginationTask = dependencies.paginationTask, + realmConfiguration = dependencies.realmConfiguration, fetchTokenAndPaginateTask = dependencies.fetchTokenAndPaginateTask, timelineEventMapper = dependencies.timelineEventMapper, uiEchoManager = uiEchoManager, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt index 77f210aa9ad..8a7078fdf96 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room.timeline import io.realm.OrderedCollectionChangeSet import io.realm.OrderedRealmCollectionChangeListener +import io.realm.RealmConfiguration import io.realm.RealmObjectChangeListener import io.realm.RealmQuery import io.realm.RealmResults @@ -36,6 +37,8 @@ import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.ChunkEntityFields import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields +import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadTimelineTask +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler import timber.log.Timber import java.util.Collections @@ -50,8 +53,10 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, private val timelineSettings: TimelineSettings, private val roomId: String, private val timelineId: String, + private val fetchThreadTimelineTask: FetchThreadTimelineTask, private val eventDecryptor: TimelineEventDecryptor, private val paginationTask: PaginationTask, + private val realmConfiguration: RealmConfiguration, private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, private val timelineEventMapper: TimelineEventMapper, private val uiEchoManager: UIEchoManager? = null, @@ -141,6 +146,9 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, val loadFromStorage = loadFromStorage(count, direction).also { logLoadedFromStorage(it, direction) } + if (loadFromStorage.numberOfEvents == 6) { + Timber.i("here") + } val offsetCount = count - loadFromStorage.numberOfEvents @@ -157,6 +165,29 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, } } + /** + * This function will fetch more live thread timeline events using the /relations api. It will + * always fetch results, while we want our data to be up to dated. + */ + suspend fun loadMoreThread(count: Int, direction: Timeline.Direction = Timeline.Direction.BACKWARDS): LoadMoreResult { + val rootThreadEventId = timelineSettings.rootThreadEventId ?: return LoadMoreResult.FAILURE + return if (direction == Timeline.Direction.BACKWARDS) { + try { + fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params( + roomId, + rootThreadEventId, + chunkEntity.prevToken, + count + )).toLoadMoreResult() + } catch (failure: Throwable) { + Timber.e(failure, "Failed to fetch thread timeline events from the server") + LoadMoreResult.FAILURE + } + } else { + LoadMoreResult.FAILURE + } + } + private suspend fun delegateLoadMore(fetchFromServerIfNeeded: Boolean, offsetCount: Int, direction: Timeline.Direction): LoadMoreResult { return if (direction == Timeline.Direction.FORWARDS) { val nextChunkEntity = chunkEntity.nextChunk @@ -413,6 +444,14 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, } } + private fun DefaultFetchThreadTimelineTask.Result.toLoadMoreResult(): LoadMoreResult { + return when (this) { + DefaultFetchThreadTimelineTask.Result.REACHED_END -> LoadMoreResult.REACHED_END + DefaultFetchThreadTimelineTask.Result.SHOULD_FETCH_MORE, + DefaultFetchThreadTimelineTask.Result.SUCCESS -> LoadMoreResult.SUCCESS + } + } + private fun getOffsetIndex(): Int { var offset = 0 var currentNextChunk = nextChunk @@ -454,6 +493,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, } } } + if (insertions.isNotEmpty() || modifications.isNotEmpty()) { onBuiltEvents(true) } @@ -487,6 +527,8 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, timelineId = timelineId, eventDecryptor = eventDecryptor, paginationTask = paginationTask, + realmConfiguration = realmConfiguration, + fetchThreadTimelineTask = fetchThreadTimelineTask, fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, timelineEventMapper = timelineEventMapper, uiEchoManager = uiEchoManager, @@ -538,7 +580,6 @@ private fun ChunkEntity.sortedTimelineEvents(rootThreadEventId: String?): RealmR .or() .equalTo(TimelineEventEntityFields.ROOT.EVENT_ID, rootThreadEventId) .endGroup() - .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) .findAll() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt index 6607e71bd9c..63383a99b3c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -34,6 +34,7 @@ import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore import org.matrix.android.sdk.internal.database.query.create import org.matrix.android.sdk.internal.database.query.find @@ -49,10 +50,10 @@ import javax.inject.Inject * Insert Chunk in DB, and eventually link next and previous chunk in db. */ internal class TokenChunkEventPersistor @Inject constructor( - @SessionDatabase private val monarchy: Monarchy, - @UserId private val userId: String, - private val lightweightSettingsStorage: LightweightSettingsStorage, - private val liveEventManager: Lazy) { + @SessionDatabase private val monarchy: Monarchy, + @UserId private val userId: String, + private val lightweightSettingsStorage: LightweightSettingsStorage, + private val liveEventManager: Lazy) { enum class Result { SHOULD_FETCH_MORE, @@ -145,9 +146,12 @@ internal class TokenChunkEventPersistor @Inject constructor( if (event.eventId == null || event.senderId == null) { return@forEach } - // We check for the timeline event with this id + // We check for the timeline event with this id, but not in the thread chunk val eventId = event.eventId - val existingTimelineEvent = TimelineEventEntity.where(realm, roomId, eventId).findFirst() + val existingTimelineEvent = TimelineEventEntity + .where(realm, roomId, eventId) + .equalTo(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, false) + .findFirst() // If it exists, we want to stop here, just link the prevChunk val existingChunk = existingTimelineEvent?.chunk?.firstOrNull() if (existingChunk != null) { @@ -173,7 +177,7 @@ internal class TokenChunkEventPersistor @Inject constructor( return@processTimelineEvents } val ageLocalTs = event.unsignedData?.age?.let { now - it } - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) + var eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) if (event.type == EventType.STATE_ROOM_MEMBER && event.stateKey != null) { val contentToUse = if (direction == PaginationDirection.BACKWARDS) { event.prevContent @@ -183,7 +187,11 @@ internal class TokenChunkEventPersistor @Inject constructor( roomMemberContentsByUser[event.stateKey] = contentToUse.toModel() } liveEventManager.get().dispatchPaginatedEventReceived(event, roomId) - currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser) + currentChunk.addTimelineEvent( + roomId = roomId, + eventEntity = eventEntity, + direction = direction, + roomMemberContentsByUser = roomMemberContentsByUser) if (lightweightSettingsStorage.areThreadMessagesEnabled()) { eventEntity.rootThreadEventId?.let { // This is a thread event diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt index a5ad19bbf80..8fe85f0d318 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt @@ -24,10 +24,12 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType 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.initsync.InitSyncStep import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummaryUpdateType import org.matrix.android.sdk.api.session.sync.model.InvitedRoomSync import org.matrix.android.sdk.api.session.sync.model.LazyRoomSyncEphemeral import org.matrix.android.sdk.api.session.sync.model.RoomSync @@ -37,6 +39,7 @@ import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.database.helper.addIfNecessary import org.matrix.android.sdk.internal.database.helper.addTimelineEvent +import org.matrix.android.sdk.internal.database.helper.createOrUpdate import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.asDomain @@ -47,10 +50,13 @@ import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.deleteOnCascade +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore import org.matrix.android.sdk.internal.database.query.find import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom +import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfThread import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.getOrNull import org.matrix.android.sdk.internal.database.query.where @@ -85,6 +91,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle private val threadsAwarenessHandler: ThreadsAwarenessHandler, private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, @UserId private val userId: String, + private val homeServerCapabilitiesService: HomeServerCapabilitiesService, private val lightweightSettingsStorage: LightweightSettingsStorage, private val timelineInput: TimelineInput, private val liveEventService: Lazy) { @@ -95,11 +102,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle data class LEFT(val data: Map) : HandlingStrategy() } - fun handle(realm: Realm, - roomsSyncResponse: RoomsSyncResponse, - isInitialSync: Boolean, - aggregator: SyncResponsePostTreatmentAggregator, - reporter: ProgressReporter? = null) { + suspend fun handle(realm: Realm, + roomsSyncResponse: RoomsSyncResponse, + isInitialSync: Boolean, + aggregator: SyncResponsePostTreatmentAggregator, + reporter: ProgressReporter? = null) { Timber.v("Execute transaction from $this") handleRoomSync(realm, HandlingStrategy.JOINED(roomsSyncResponse.join), isInitialSync, aggregator, reporter) handleRoomSync(realm, HandlingStrategy.INVITED(roomsSyncResponse.invite), isInitialSync, aggregator, reporter) @@ -114,11 +121,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } // PRIVATE METHODS ***************************************************************************** - private fun handleRoomSync(realm: Realm, - handlingStrategy: HandlingStrategy, - isInitialSync: Boolean, - aggregator: SyncResponsePostTreatmentAggregator, - reporter: ProgressReporter?) { + private suspend fun handleRoomSync(realm: Realm, + handlingStrategy: HandlingStrategy, + isInitialSync: Boolean, + aggregator: SyncResponsePostTreatmentAggregator, + reporter: ProgressReporter?) { val insertType = if (isInitialSync) { EventInsertType.INITIAL_SYNC } else { @@ -151,11 +158,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle realm.insertOrUpdate(rooms) } - private fun insertJoinRoomsFromInitSync(realm: Realm, - handlingStrategy: HandlingStrategy.JOINED, - syncLocalTimeStampMillis: Long, - aggregator: SyncResponsePostTreatmentAggregator, - reporter: ProgressReporter?) { + private suspend fun insertJoinRoomsFromInitSync(realm: Realm, + handlingStrategy: HandlingStrategy.JOINED, + syncLocalTimeStampMillis: Long, + aggregator: SyncResponsePostTreatmentAggregator, + reporter: ProgressReporter?) { val bestChunkSize = computeBestChunkSize( listSize = handlingStrategy.data.keys.size, limit = (initialSyncStrategy as? InitialSyncStrategy.Optimized)?.maxRoomsToInsert ?: Int.MAX_VALUE @@ -193,12 +200,12 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } } - private fun handleJoinedRoom(realm: Realm, - roomId: String, - roomSync: RoomSync, - insertType: EventInsertType, - syncLocalTimestampMillis: Long, - aggregator: SyncResponsePostTreatmentAggregator): RoomEntity { + private suspend fun handleJoinedRoom(realm: Realm, + roomId: String, + roomSync: RoomSync, + insertType: EventInsertType, + syncLocalTimestampMillis: Long, + aggregator: SyncResponsePostTreatmentAggregator): RoomEntity { Timber.v("Handle join sync for room $roomId") val ephemeralResult = (roomSync.ephemeral as? LazyRoomSyncEphemeral.Parsed) @@ -344,15 +351,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle return roomEntity } - private fun handleTimelineEvents(realm: Realm, - roomId: String, - roomEntity: RoomEntity, - eventList: List, - prevToken: String? = null, - isLimited: Boolean = true, - insertType: EventInsertType, - syncLocalTimestampMillis: Long, - aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity { + private suspend fun handleTimelineEvents(realm: Realm, + roomId: String, + roomEntity: RoomEntity, + eventList: List, + prevToken: String? = null, + isLimited: Boolean = true, + insertType: EventInsertType, + syncLocalTimestampMillis: Long, + aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity { val lastChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomEntity.roomId) if (isLimited && lastChunk != null) { lastChunk.deleteOnCascade(deleteStateEvents = false, canDeleteRoot = true) @@ -409,11 +416,28 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle rootStateEvent?.asDomain()?.getFixedRoomMemberContent() } - chunkEntity.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser) + val timelineEventAdded = chunkEntity.addTimelineEvent( + roomId = roomId, + eventEntity = eventEntity, + direction = PaginationDirection.FORWARDS, + roomMemberContentsByUser = roomMemberContentsByUser) if (lightweightSettingsStorage.areThreadMessagesEnabled()) { eventEntity.rootThreadEventId?.let { // This is a thread event optimizedThreadSummaryMap[it] = eventEntity + // Add the same thread timeline event to Thread Chunk + addToThreadChunkIfNeeded(realm, roomId, it, timelineEventAdded, roomEntity) + if (homeServerCapabilitiesService.getHomeServerCapabilities().canUseThreading) { + // Update thread summaries only if homeserver supports threading + ThreadSummaryEntity.createOrUpdate( + threadSummaryType = ThreadSummaryUpdateType.ADD, + realm = realm, + roomId = roomId, + threadEventEntity = eventEntity, + roomMemberContentsByUser = roomMemberContentsByUser, + userId = userId, + roomEntity = roomEntity) + } } ?: run { // This is a normal event or a root thread one optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity @@ -458,6 +482,28 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle return chunkEntity } + /** + * Adds new event to the appropriate thread chunk. If the event is already in + * the thread timeline and /relations api, we should not added it + */ + private fun addToThreadChunkIfNeeded(realm: Realm, + roomId: String, + threadId: String, + timelineEventEntity: TimelineEventEntity?, + roomEntity: RoomEntity) { + val eventId = timelineEventEntity?.eventId ?: return + + ChunkEntity.findLastForwardChunkOfThread(realm, roomId, threadId)?.let { threadChunk -> + val existingEvent = threadChunk.timelineEvents.find(eventId) + if (existingEvent?.ownedByThreadChunk == true) { + Timber.i("###THREADS RoomSyncHandler event:${timelineEventEntity.eventId} already exists, do not add") + return@addToThreadChunkIfNeeded + } + threadChunk.timelineEvents.add(0, timelineEventEntity) + roomEntity.addIfNecessary(threadChunk) + } + } + private suspend fun decryptIfNeeded(event: Event, roomId: String) { try { // Event from sync does not have roomId, so add it to the event first diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt index f3a15239553..db9799d51eb 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt @@ -32,6 +32,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageFormat import org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.sync.model.SyncResponse import org.matrix.android.sdk.api.util.JsonDict @@ -161,7 +162,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( eventEntity: EventEntity? = null): String? { event ?: return null roomId ?: return null - if (lightweightSettingsStorage.areThreadMessagesEnabled()) return null + if (lightweightSettingsStorage.areThreadMessagesEnabled() && !isReplyEvent(event)) return null handleRootThreadEventsIfNeeded(realm, roomId, eventEntity, event) if (!isThreadEvent(event)) return null val eventPayload = if (!event.isEncrypted()) { @@ -170,8 +171,9 @@ internal class ThreadsAwarenessHandler @Inject constructor( event.mxDecryptionResult?.payload?.toMutableMap() ?: return null } val eventBody = event.getDecryptedTextSummary() ?: return null + val threadRelation = getRootThreadRelationContent(event) val eventIdToInject = getPreviousEventOrRoot(event) ?: run { - return@makeEventThreadAware injectFallbackIndicator(event, eventBody, eventEntity, eventPayload) + return@makeEventThreadAware injectFallbackIndicator(event, eventBody, eventEntity, eventPayload, threadRelation) } val eventToInject = getEventFromDB(realm, eventIdToInject) val eventToInjectBody = eventToInject?.getDecryptedTextSummary() @@ -183,17 +185,19 @@ internal class ThreadsAwarenessHandler @Inject constructor( roomId = roomId, eventBody = eventBody, eventToInject = eventToInject, - eventToInjectBody = eventToInjectBody) ?: return null + eventToInjectBody = eventToInjectBody, + threadRelation = threadRelation) ?: return null + // update the event contentForNonEncrypted = updateEventEntity(event, eventEntity, eventPayload, messageTextContent) } else { - contentForNonEncrypted = injectFallbackIndicator(event, eventBody, eventEntity, eventPayload) + contentForNonEncrypted = injectFallbackIndicator(event, eventBody, eventEntity, eventPayload, threadRelation) } // Now lets try to find relations for improved results, while some events may come with reverse order eventEntity?.let { // When eventEntity is not null means that we are not from within roomSyncHandler - handleEventsThatRelatesTo(realm, roomId, event, eventBody, false) + handleEventsThatRelatesTo(realm, roomId, event, eventBody, false, threadRelation) } return contentForNonEncrypted } @@ -205,11 +209,16 @@ internal class ThreadsAwarenessHandler @Inject constructor( * @param event the current event received * @return The content to inject in the roomSyncHandler live events */ - private fun handleRootThreadEventsIfNeeded(realm: Realm, roomId: String, eventEntity: EventEntity?, event: Event): String? { + private fun handleRootThreadEventsIfNeeded( + realm: Realm, + roomId: String, + eventEntity: EventEntity?, + event: Event + ): String? { if (!isThreadEvent(event) && cacheEventRootId.contains(eventEntity?.eventId)) { eventEntity?.let { val eventBody = event.getDecryptedTextSummary() ?: return null - return handleEventsThatRelatesTo(realm, roomId, event, eventBody, true) + return handleEventsThatRelatesTo(realm, roomId, event, eventBody, true, null) } } return null @@ -224,7 +233,14 @@ internal class ThreadsAwarenessHandler @Inject constructor( * @param isFromCache determines whether or not we already know this is root thread event * @return The content to inject in the roomSyncHandler live events */ - private fun handleEventsThatRelatesTo(realm: Realm, roomId: String, event: Event, eventBody: String, isFromCache: Boolean): String? { + private fun handleEventsThatRelatesTo( + realm: Realm, + roomId: String, + event: Event, + eventBody: String, + isFromCache: Boolean, + threadRelation: RelationDefaultContent? + ): String? { event.eventId ?: return null val rootThreadEventId = if (isFromCache) event.eventId else event.getRootThreadEventId() ?: return null eventThatRelatesTo(realm, event.eventId, rootThreadEventId)?.forEach { eventEntityFound -> @@ -236,7 +252,8 @@ internal class ThreadsAwarenessHandler @Inject constructor( roomId = roomId, eventBody = newEventBody, eventToInject = event, - eventToInjectBody = eventBody) ?: return null + eventToInjectBody = eventBody, + threadRelation = threadRelation) ?: return null return updateEventEntity(newEventFound, eventEntityFound, newEventPayload, messageTextContent) } @@ -280,7 +297,9 @@ internal class ThreadsAwarenessHandler @Inject constructor( private fun injectEvent(roomId: String, eventBody: String, eventToInject: Event, - eventToInjectBody: String): Content? { + eventToInjectBody: String, + threadRelation: RelationDefaultContent? + ): Content? { val eventToInjectId = eventToInject.eventId ?: return null val eventIdToInjectSenderId = eventToInject.senderId.orEmpty() val permalink = permalinkFactory.createPermalink(roomId, eventToInjectId, false) @@ -293,6 +312,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( eventBody) return MessageTextContent( + relatesTo = threadRelation, msgType = MessageType.MSGTYPE_TEXT, format = MessageFormat.FORMAT_MATRIX_HTML, body = eventBody, @@ -306,12 +326,14 @@ internal class ThreadsAwarenessHandler @Inject constructor( private fun injectFallbackIndicator(event: Event, eventBody: String, eventEntity: EventEntity?, - eventPayload: MutableMap): String? { + eventPayload: MutableMap, + threadRelation: RelationDefaultContent?): String? { val replyFormatted = LocalEchoEventFactory.QUOTE_PATTERN.format( "In reply to a thread", eventBody) val messageTextContent = MessageTextContent( + relatesTo = threadRelation, msgType = MessageType.MSGTYPE_TEXT, format = MessageFormat.FORMAT_MATRIX_HTML, body = eventBody, @@ -332,7 +354,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( .findAll() cacheEventRootId.add(rootThreadEventId) return threadList.filter { - it.asDomain().getRelationContentForType(RelationType.IO_THREAD)?.inReplyTo?.eventId == currentEventId + it.asDomain().getRelationContentForType(RelationType.THREAD)?.inReplyTo?.eventId == currentEventId } } @@ -350,7 +372,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( * @param event */ private fun isThreadEvent(event: Event): Boolean = - event.content.toModel()?.relatesTo?.type == RelationType.IO_THREAD + event.content.toModel()?.relatesTo?.type == RelationType.THREAD /** * Returns the root thread eventId or null otherwise @@ -359,9 +381,22 @@ internal class ThreadsAwarenessHandler @Inject constructor( private fun getRootThreadEventId(event: Event): String? = event.content.toModel()?.relatesTo?.eventId + private fun getRootThreadRelationContent(event: Event): RelationDefaultContent? = + event.content.toModel()?.relatesTo + private fun getPreviousEventOrRoot(event: Event): String? = event.content.toModel()?.relatesTo?.inReplyTo?.eventId + /** + * Returns if we should html inject the current event. + */ + private fun isReplyEvent(event: Event): Boolean { + return isThreadEvent(event) && !isFallingBack(event) && getPreviousEventOrRoot(event) != null + } + + private fun isFallingBack(event: Event): Boolean = + event.content.toModel()?.relatesTo?.isFallingBack == true + @Suppress("UNCHECKED_CAST") private fun getValueFromPayload(payload: JsonDict?, key: String): String? { val content = payload?.get("content") as? JsonDict diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/api/auth/data/VersionsKtTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/api/auth/data/VersionsKtTest.kt index 96655b849df..088e1609503 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/api/auth/data/VersionsKtTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/api/auth/data/VersionsKtTest.kt @@ -19,6 +19,7 @@ package org.matrix.android.sdk.api.auth.data import org.amshove.kluent.shouldBe import org.junit.Test import org.matrix.android.sdk.internal.auth.version.Versions +import org.matrix.android.sdk.internal.auth.version.doesServerSupportThreads import org.matrix.android.sdk.internal.auth.version.isSupportedBySdk class VersionsKtTest { @@ -53,5 +54,20 @@ class VersionsKtTest { Versions(supportedVersions = listOf("r0.5.0", "r0.6.0")).isSupportedBySdk() shouldBe true Versions(supportedVersions = listOf("r0.5.0", "r0.6.1")).isSupportedBySdk() shouldBe true Versions(supportedVersions = listOf("r0.6.0")).isSupportedBySdk() shouldBe true + Versions(supportedVersions = listOf("v1.6.0")).isSupportedBySdk() shouldBe true + } + + @Test + fun doesServerSupportThreads() { + Versions(supportedVersions = listOf("r0.6.0")).doesServerSupportThreads() shouldBe false + Versions(supportedVersions = listOf("r0.9.1")).doesServerSupportThreads() shouldBe false + Versions(supportedVersions = listOf("v1.2.0")).doesServerSupportThreads() shouldBe false + Versions(supportedVersions = listOf("v1.3.0")).doesServerSupportThreads() shouldBe true + Versions(supportedVersions = listOf("v1.3.1")).doesServerSupportThreads() shouldBe true + Versions(supportedVersions = listOf("v1.5.1")).doesServerSupportThreads() shouldBe true + Versions(supportedVersions = listOf("r0.6.0"), unstableFeatures = mapOf("org.matrix.msc3440.stable" to true)).doesServerSupportThreads() shouldBe true + Versions(supportedVersions = listOf("v1.2.1"), unstableFeatures = mapOf("org.matrix.msc3440.stable" to true)).doesServerSupportThreads() shouldBe true + Versions(supportedVersions = listOf("r0.6.0"), unstableFeatures = mapOf("org.matrix.msc3440.stable" to false)).doesServerSupportThreads() shouldBe false + Versions(supportedVersions = listOf("v1.4.0"), unstableFeatures = mapOf("org.matrix.msc3440.stable" to false)).doesServerSupportThreads() shouldBe true } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt index 417d28d6252..5a03d5890a3 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt @@ -27,6 +27,7 @@ import im.vector.app.espresso.tools.ScreenshotFailureRule import im.vector.app.features.MainActivity import im.vector.app.getString import im.vector.app.ui.robot.ElementRobot +import im.vector.app.ui.robot.settings.labs.LabFeature import im.vector.app.ui.robot.withDeveloperMode import org.junit.Rule import org.junit.Test @@ -97,6 +98,8 @@ class UiAllScreensSanityTest { } } + testThreadScreens() + elementRobot.space { createSpace { crawl() @@ -148,4 +151,25 @@ class UiAllScreensSanityTest { // TODO Deactivate account instead of logout? elementRobot.signout(expectSignOutWarning = false) } + + /** + * Testing multiple threads screens + */ + private fun testThreadScreens() { + elementRobot.toggleLabFeature(LabFeature.THREAD_MESSAGES) + elementRobot.newRoom { + createNewRoom { + crawl() + createRoom { + val message = "Hello This message will be a thread!" + postMessage(message) + replyToThread(message) + viewInRoom(message) + openThreadSummaries() + selectThreadSummariesFilter() + } + } + } + elementRobot.toggleLabFeature(LabFeature.THREAD_MESSAGES) + } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt index f0ce23b7db1..3c5de8b2218 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt @@ -17,9 +17,15 @@ package im.vector.app.ui.robot import android.view.View +import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.pressBack +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions.assertDisplayed import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn import com.adevinta.android.barista.interaction.BaristaDialogInteractions.clickDialogNegativeButton @@ -35,6 +41,7 @@ import im.vector.app.features.home.HomeActivity import im.vector.app.features.onboarding.OnboardingActivity import im.vector.app.initialSyncIdlingResource import im.vector.app.ui.robot.settings.SettingsRobot +import im.vector.app.ui.robot.settings.labs.LabFeature import im.vector.app.ui.robot.space.SpaceRobot import im.vector.app.withIdlingResource import timber.log.Timber @@ -70,11 +77,11 @@ class ElementRobot { } } - fun settings(block: SettingsRobot.() -> Unit) { + fun settings(shouldGoBack: Boolean = true, block: SettingsRobot.() -> Unit) { openDrawer() clickOn(R.id.homeDrawerHeaderSettingsView) block(SettingsRobot()) - pressBack() + if (shouldGoBack) pressBack() waitUntilViewVisible(withId(R.id.bottomNavigationView)) } @@ -103,6 +110,22 @@ class ElementRobot { waitUntilViewVisible(withId(R.id.bottomNavigationView)) } + fun toggleLabFeature(labFeature: LabFeature) { + when (labFeature) { + LabFeature.THREAD_MESSAGES -> { + settings(shouldGoBack = false) { + labs(shouldGoBack = false) { + onView(withText(R.string.labs_enable_thread_messages)) + .check(ViewAssertions.matches(isDisplayed())) + .perform(ViewActions.closeSoftKeyboard(), click()) + } + } + } + else -> { + } + } + } + fun signout(expectSignOutWarning: Boolean) { clickOn(R.id.groupToolbarAvatarImageView) clickOn(R.id.homeDrawerHeaderSignoutView) diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/MessageMenuRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/MessageMenuRobot.kt index 5973dc34730..5c9ecfdef54 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/MessageMenuRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/MessageMenuRobot.kt @@ -70,4 +70,13 @@ class MessageMenuRobot( clickOn(R.string.edit) autoClosed = true } + + fun replyInThread() { + clickOn(R.string.reply_in_thread) + autoClosed = true + } + fun viewInRoom() { + clickOn(R.string.view_in_room) + autoClosed = true + } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt index 6cf6ad35517..91409582d96 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt @@ -62,6 +62,23 @@ class RoomDetailRobot { pressBack() } + fun replyToThread(message: String) { + openMessageMenu(message) { + replyInThread() + } + val threadMessage = "Hello universe - long message to avoid espresso tapping edited!" + writeTo(R.id.composerEditText, threadMessage) + waitUntilViewVisible(withId(R.id.sendButton)) + clickOn(R.id.sendButton) + } + + fun viewInRoom(message: String) { + openMessageMenu(message) { + viewInRoom() + } + waitUntilViewVisible(withId(R.id.composerEditText)) + } + fun crawlMessage(message: String) { // Test quick reaction val quickReaction = EmojiDataSource.quickEmojis[0] // 👍 @@ -110,7 +127,7 @@ class RoomDetailRobot { onView(withId(R.id.timelineRecyclerView)) .perform( RecyclerViewActions.actionOnItem( - ViewMatchers.hasDescendant(ViewMatchers.withText(message)), + ViewMatchers.hasDescendant(withText(message)), ViewActions.longClick() ) ) @@ -130,4 +147,16 @@ class RoomDetailRobot { block(RoomSettingsRobot()) pressBack() } + + fun openThreadSummaries() { + clickMenu(R.id.menu_timeline_thread_list) + waitUntilViewVisible(withId(R.id.threadListRecyclerView)) + } + + fun selectThreadSummariesFilter() { + clickMenu(R.id.menu_thread_list_filter) + sleep(1000) + clickOn(R.id.threadListModalMyThreads) + pressBack() + } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsRobot.kt index 561f14c6f22..97aee7ac4a9 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/SettingsRobot.kt @@ -16,6 +16,7 @@ package im.vector.app.ui.robot.settings +import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn import im.vector.app.R import im.vector.app.clickOnAndGoBack @@ -51,8 +52,13 @@ class SettingsRobot { clickOnAndGoBack(R.string.settings_security_and_privacy) { block(SettingsSecurityRobot()) } } - fun labs(block: () -> Unit = {}) { - clickOnAndGoBack(R.string.room_settings_labs_pref_title) { block() } + fun labs(shouldGoBack: Boolean = true, block: () -> Unit = {}) { + if (shouldGoBack) { + clickOnAndGoBack(R.string.room_settings_labs_pref_title) { block() } + } else { + clickOn(R.string.room_settings_labs_pref_title) + block() + } } fun advancedSettings(block: SettingsAdvancedRobot.() -> Unit) { diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/labs/LabFeature.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/labs/LabFeature.kt new file mode 100644 index 00000000000..656201d8128 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/labs/LabFeature.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 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.app.ui.robot.settings.labs + +enum class LabFeature { + SWIPE_TO_REPLY, + TAB_UNREAD_NOTIFICATIONS, + LATEX_MATHEMATICS, + THREAD_MESSAGES, + AUTO_REPORT_ERRORS, + RENDER_USER_LOCATION +} 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 3bdcbc6529d..a9235b56994 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 @@ -158,6 +158,9 @@ class TimelineViewModel @AssistedInject constructor( companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { const val PAGINATION_COUNT = 50 + + // The larger the number the faster the results, COUNT=200 for 500 thread messages its x4 faster than COUNT=50 + const val PAGINATION_COUNT_THREADS_PERMALINK = 200 } init { @@ -503,7 +506,10 @@ class TimelineViewModel @AssistedInject constructor( private fun handleSendSticker(action: RoomDetailAction.SendSticker) { val content = initialState.rootThreadEventId?.let { - action.stickerContent.copy(relatesTo = RelationDefaultContent(RelationType.IO_THREAD, it)) + action.stickerContent.copy(relatesTo = RelationDefaultContent( + type = RelationType.THREAD, + isFallingBack = true, + eventId = it)) } ?: action.stickerContent room.sendEvent(EventType.STICKER, content.toContent()) @@ -1175,10 +1181,30 @@ class TimelineViewModel @AssistedInject constructor( } } + /** + * Navigates to the appropriate event (by paginating the thread timeline until the event is found + * in the snapshot. The main reason for this function is to support the /relations api + */ + private var threadPermalinkHandled = false + private fun navigateToThreadEventIfNeeded(snapshot: List) { + if (eventId != null && initialState.rootThreadEventId != null) { + // When we have a permalink and we are in a thread timeline + if (snapshot.firstOrNull { it.eventId == eventId } != null && !threadPermalinkHandled) { + // Permalink event found lets navigate there + handleNavigateToEvent(RoomDetailAction.NavigateToEvent(eventId, true)) + threadPermalinkHandled = true + } else { + // Permalink event not found yet continue paginating + timeline.paginate(Timeline.Direction.BACKWARDS, PAGINATION_COUNT_THREADS_PERMALINK) + } + } + } + override fun onTimelineUpdated(snapshot: List) { viewModelScope.launch { // tryEmit doesn't work with SharedFlow without cache timelineEvents.emit(snapshot) + navigateToThreadEventIfNeeded(snapshot) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index 325e9b93302..f7975c90293 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -465,7 +465,8 @@ class MessageComposerViewModel @AssistedInject constructor( // is original event a reply? val relationContent = state.sendMode.timelineEvent.getRelationContent() val inReplyTo = if (state.rootThreadEventId != null) { - if (relationContent?.inReplyTo?.shouldRenderInThread() == true) { + // Thread event + if (relationContent?.shouldRenderInThread() == true) { // Reply within a thread event relationContent.inReplyTo?.eventId } else { @@ -509,6 +510,7 @@ class MessageComposerViewModel @AssistedInject constructor( is SendMode.Reply -> { val timelineEvent = state.sendMode.timelineEvent val showInThread = state.sendMode.timelineEvent.root.isThread() && state.rootThreadEventId == null + // If threads are disabled this will make the fallback replies visible to clients with threads enabled val rootThreadEventId = if (showInThread) timelineEvent.root.getRootThreadEventId() else null state.rootThreadEventId?.let { room.replyInThread( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt index 2cdc1a0d908..5b1f17cfe26 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt @@ -32,6 +32,7 @@ import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.core.ui.list.GenericHeaderItem_ import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.Content @@ -45,6 +46,7 @@ class SearchResultController @Inject constructor( private val avatarRenderer: AvatarRenderer, private val stringProvider: StringProvider, private val dateFormatter: VectorDateFormatter, + private val displayableEventFormatter: DisplayableEventFormatter, private val userPreferencesProvider: UserPreferencesProvider ) : TypedEpoxyController() { @@ -125,6 +127,7 @@ class SearchResultController @Inject constructor( .sender(eventAndSender.sender ?: eventAndSender.event.senderId?.let { session.getRoomMember(it, data.roomId) }?.toMatrixItem()) .threadDetails(event.threadDetails) + .threadSummaryFormatted(displayableEventFormatter.formatThreadSummary(event.threadDetails?.threadSummaryLatestEvent).toString()) .areThreadMessagesEnabled(userPreferencesProvider.areThreadMessagesEnabled()) .listener { listener?.onItemClicked(eventAndSender.event) } .let { result.add(it) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt index 2ec786fab2f..3e141ab0e9d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt @@ -42,6 +42,7 @@ abstract class SearchResultItem : VectorEpoxyModel() { @EpoxyAttribute lateinit var spannable: EpoxyCharSequence @EpoxyAttribute var sender: MatrixItem? = null @EpoxyAttribute var threadDetails: ThreadDetails? = null + @EpoxyAttribute var threadSummaryFormatted: String? = null @EpoxyAttribute var areThreadMessagesEnabled: Boolean = false @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var listener: ClickListener? = null @@ -60,8 +61,7 @@ abstract class SearchResultItem : VectorEpoxyModel() { if (it.isRootThread) { showThreadSummary(holder) holder.threadSummaryCounterTextView.text = it.numberOfThreads.toString() - holder.threadSummaryInfoTextView.text = it.threadSummaryLatestTextMessage.orEmpty() - + holder.threadSummaryInfoTextView.text = threadSummaryFormatted.orEmpty() val userId = it.threadSummarySenderInfo?.userId ?: return@let val displayName = it.threadSummarySenderInfo?.displayName val avatarUrl = it.threadSummarySenderInfo?.avatarUrl diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt index 1c339e6cf4e..b83322dc9b7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt @@ -24,9 +24,11 @@ import im.vector.app.core.resources.StringProvider import im.vector.app.features.html.EventHtmlRenderer import me.gujun.android.span.span import org.commonmark.node.Document +import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent +import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType @@ -139,6 +141,98 @@ class DisplayableEventFormatter @Inject constructor( } } + fun formatThreadSummary( + event: Event?, + latestEdition: String? = null): CharSequence { + event ?: return "" + + // There event have been edited + if (latestEdition != null) { + return run { + val localFormattedBody = htmlRenderer.get().parse(latestEdition) as Document + val renderedBody = htmlRenderer.get().render(localFormattedBody) ?: latestEdition + renderedBody + } + } + + // The event have been redacted + if (event.isRedacted()) { + return noticeEventFormatter.formatRedactedEvent(event) + } + + // The event is encrypted + if (event.isEncrypted() && + event.mxDecryptionResult == null) { + return stringProvider.getString(R.string.encrypted_message) + } + + return when (event.getClearType()) { + EventType.MESSAGE -> { + (event.getClearContent().toModel() as? MessageContent)?.let { messageContent -> + when (messageContent.msgType) { + MessageType.MSGTYPE_TEXT -> { + val body = messageContent.getTextDisplayableContent() + if (messageContent is MessageTextContent && messageContent.matrixFormattedBody.isNullOrBlank().not()) { + val localFormattedBody = htmlRenderer.get().parse(body) as Document + val renderedBody = htmlRenderer.get().render(localFormattedBody) ?: body + renderedBody + } else { + body + } + } + MessageType.MSGTYPE_VERIFICATION_REQUEST -> { + stringProvider.getString(R.string.verification_request) + } + MessageType.MSGTYPE_IMAGE -> { + stringProvider.getString(R.string.sent_an_image) + } + MessageType.MSGTYPE_AUDIO -> { + if ((messageContent as? MessageAudioContent)?.voiceMessageIndicator != null) { + stringProvider.getString(R.string.sent_a_voice_message) + } else { + stringProvider.getString(R.string.sent_an_audio_file) + } + } + MessageType.MSGTYPE_VIDEO -> { + stringProvider.getString(R.string.sent_a_video) + } + MessageType.MSGTYPE_FILE -> { + stringProvider.getString(R.string.sent_a_file) + } + MessageType.MSGTYPE_LOCATION -> { + stringProvider.getString(R.string.sent_location) + } + else -> { + messageContent.body + } + } + } ?: span { } + } + EventType.STICKER -> { + stringProvider.getString(R.string.send_a_sticker) + } + EventType.REACTION -> { + event.getClearContent().toModel()?.relatesTo?.let { + emojiSpanify.spanify(stringProvider.getString(R.string.sent_a_reaction, it.key)) + } ?: span { } + } + in EventType.POLL_START -> { + event.getClearContent().toModel(catchError = true)?.pollCreationInfo?.question?.question + ?: stringProvider.getString(R.string.sent_a_poll) + } + in EventType.POLL_RESPONSE -> { + stringProvider.getString(R.string.poll_response_room_list_preview) + } + in EventType.POLL_END -> { + stringProvider.getString(R.string.poll_end_room_list_preview) + } + else -> { + span { + } + } + } + } + private fun simpleFormat(senderName: String, body: CharSequence, appendAuthor: Boolean): CharSequence { return if (appendAuthor) { span { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt index 426561054b3..45c711ff93e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt @@ -22,6 +22,7 @@ import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryEvents @@ -33,6 +34,7 @@ class MessageItemAttributesFactory @Inject constructor( private val messageColorProvider: MessageColorProvider, private val avatarSizeProvider: AvatarSizeProvider, private val stringProvider: StringProvider, + private val displayableEventFormatter: DisplayableEventFormatter, private val preferencesProvider: UserPreferencesProvider, private val emojiCompatFontProvider: EmojiCompatFontProvider) { @@ -61,6 +63,7 @@ class MessageItemAttributesFactory @Inject constructor( readReceiptsCallback = callback, emojiTypeFace = emojiCompatFontProvider.typeface, decryptionErrorMessage = stringProvider.getString(R.string.encrypted_message), + threadSummaryFormatted = displayableEventFormatter.formatThreadSummary(threadDetails?.threadSummaryLatestEvent).toString(), threadDetails = threadDetails, reactionsSummaryEvents = reactionsSummaryEvents, areThreadMessagesEnabled = preferencesProvider.areThreadMessagesEnabled() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt index 2fac9df6651..30c366738d4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -116,7 +116,7 @@ abstract class AbsMessageItem : AbsBaseMessageItem attributes.threadDetails?.let { threadDetails -> holder.threadSummaryConstraintLayout.isVisible = threadDetails.isRootThread holder.threadSummaryCounterTextView.text = threadDetails.numberOfThreads.toString() - holder.threadSummaryInfoTextView.text = threadDetails.threadSummaryLatestTextMessage ?: attributes.decryptionErrorMessage + holder.threadSummaryInfoTextView.text = attributes.threadSummaryFormatted ?: attributes.decryptionErrorMessage val userId = threadDetails.threadSummarySenderInfo?.userId ?: return@let val displayName = threadDetails.threadSummarySenderInfo?.displayName @@ -184,6 +184,7 @@ abstract class AbsMessageItem : AbsBaseMessageItem override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, val emojiTypeFace: Typeface? = null, val decryptionErrorMessage: String? = null, + val threadSummaryFormatted: String? = null, val threadDetails: ThreadDetails? = null, val areThreadMessagesEnabled: Boolean = false, override val reactionsSummaryEvents: ReactionsSummaryEvents? = null, diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt index ca18060c51b..fc76535c4c7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt @@ -32,7 +32,6 @@ import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.threads.arguments.ThreadListArgs import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.home.room.threads.list.views.ThreadListFragment -import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import javax.inject.Inject @AndroidEntryPoint @@ -92,14 +91,7 @@ class ThreadsActivity : VectorBaseActivity() { * This function is used to navigate to the selected thread timeline. * One usage of that is from the Threads Activity */ - fun navigateToThreadTimeline( - timelineEvent: TimelineEvent) { - val roomThreadDetailArgs = ThreadTimelineArgs( - roomId = timelineEvent.roomId, - displayName = timelineEvent.senderInfo.displayName, - avatarUrl = timelineEvent.senderInfo.avatarUrl, - roomEncryptionTrustLevel = null, - rootThreadEventId = timelineEvent.eventId) + fun navigateToThreadTimeline(threadTimelineArgs: ThreadTimelineArgs) { val commonOption: (FragmentTransaction) -> Unit = { it.setCustomAnimations( R.anim.animation_slide_in_right, @@ -111,8 +103,8 @@ class ThreadsActivity : VectorBaseActivity() { container = views.threadsActivityFragmentContainer, fragmentClass = TimelineFragment::class.java, params = TimelineArgs( - roomId = timelineEvent.roomId, - threadTimelineArgs = roomThreadDetailArgs + roomId = threadTimelineArgs.roomId, + threadTimelineArgs = threadTimelineArgs ), option = commonOption ) diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt index 8bc6bd73e90..aeef69c6dcf 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt @@ -17,21 +17,26 @@ package im.vector.app.features.home.room.threads.list.viewmodel import com.airbnb.epoxy.EpoxyController -import im.vector.app.R import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.resources.StringProvider import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter import im.vector.app.features.home.room.threads.list.model.threadListItem +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.threads.ThreadNotificationState import org.matrix.android.sdk.api.util.toMatrixItem +import org.matrix.android.sdk.api.util.toMatrixItemOrNull import javax.inject.Inject class ThreadListController @Inject constructor( private val avatarRenderer: AvatarRenderer, private val stringProvider: StringProvider, - private val dateFormatter: VectorDateFormatter + private val dateFormatter: VectorDateFormatter, + private val displayableEventFormatter: DisplayableEventFormatter, + private val session: Session ) : EpoxyController() { var listener: Listener? = null @@ -43,10 +48,68 @@ class ThreadListController @Inject constructor( requestModelBuild() } - override fun buildModels() { + override fun buildModels() = + when (session.getHomeServerCapabilities().canUseThreading) { + true -> buildThreadSummaries() + false -> buildThreadList() + } + + /** + * Building thread summaries when homeserver + * supports threading + */ + private fun buildThreadSummaries() { val safeViewState = viewState ?: return val host = this + safeViewState.threadSummaryList.invoke() + ?.filter { + if (safeViewState.shouldFilterThreads) { + it.isUserParticipating + } else { + true + } + } + ?.forEach { threadSummary -> + val date = dateFormatter.format(threadSummary.latestEvent?.originServerTs, DateFormatKind.ROOM_LIST) + val lastMessageFormatted = threadSummary.let { + displayableEventFormatter.formatThreadSummary( + event = it.latestEvent, + latestEdition = it.threadEditions.latestThreadEdition + ).toString() + } + val rootMessageFormatted = threadSummary.let { + displayableEventFormatter.formatThreadSummary( + event = it.rootEvent, + latestEdition = it.threadEditions.rootThreadEdition + ).toString() + } + threadListItem { + id(threadSummary.rootEvent?.eventId) + avatarRenderer(host.avatarRenderer) + matrixItem(threadSummary.rootThreadSenderInfo.toMatrixItem()) + title(threadSummary.rootThreadSenderInfo.displayName.orEmpty()) + date(date) + rootMessageDeleted(threadSummary.rootEvent?.isRedacted() ?: false) + // TODO refactor notifications that with the new thread summary + threadNotificationState(threadSummary.rootEvent?.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE) + rootMessage(rootMessageFormatted) + lastMessage(lastMessageFormatted) + lastMessageCounter(threadSummary.numberOfThreads.toString()) + lastMessageMatrixItem(threadSummary.latestThreadSenderInfo.toMatrixItemOrNull()) + itemClickListener { + host.listener?.onThreadSummaryClicked(threadSummary) + } + } + } + } + /** + * Building local thread list when homeserver do not + * support threading + */ + private fun buildThreadList() { + val safeViewState = viewState ?: return + val host = this safeViewState.rootThreadEventList.invoke() ?.filter { if (safeViewState.shouldFilterThreads) { @@ -59,28 +122,39 @@ class ThreadListController @Inject constructor( } ?.forEach { timelineEvent -> val date = dateFormatter.format(timelineEvent.root.threadDetails?.lastMessageTimestamp, DateFormatKind.ROOM_LIST) - val decryptionErrorMessage = stringProvider.getString(R.string.encrypted_message) val lastRootThreadEdition = timelineEvent.root.threadDetails?.lastRootThreadEdition + val lastMessageFormatted = timelineEvent.root.threadDetails?.threadSummaryLatestEvent.let { + displayableEventFormatter.formatThreadSummary( + event = it, + ).toString() + } + val rootMessageFormatted = timelineEvent.root.let { + displayableEventFormatter.formatThreadSummary( + event = it, + latestEdition = lastRootThreadEdition + ).toString() + } threadListItem { id(timelineEvent.eventId) avatarRenderer(host.avatarRenderer) matrixItem(timelineEvent.senderInfo.toMatrixItem()) - title(timelineEvent.senderInfo.displayName) + title(timelineEvent.senderInfo.displayName.orEmpty()) date(date) rootMessageDeleted(timelineEvent.root.isRedacted()) threadNotificationState(timelineEvent.root.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE) - rootMessage(lastRootThreadEdition ?: timelineEvent.root.getDecryptedTextSummary() ?: decryptionErrorMessage) - lastMessage(timelineEvent.root.threadDetails?.threadSummaryLatestTextMessage ?: decryptionErrorMessage) + rootMessage(rootMessageFormatted) + lastMessage(lastMessageFormatted) lastMessageCounter(timelineEvent.root.threadDetails?.numberOfThreads.toString()) lastMessageMatrixItem(timelineEvent.root.threadDetails?.threadSummarySenderInfo?.toMatrixItem()) itemClickListener { - host.listener?.onThreadClicked(timelineEvent) + host.listener?.onThreadListClicked(timelineEvent) } } } } interface Listener { - fun onThreadClicked(timelineEvent: TimelineEvent) + fun onThreadSummaryClicked(threadSummary: ThreadSummary) + fun onThreadListClicked(timelineEvent: TimelineEvent) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt index d82b5d6ccf5..7a22f75bce7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt @@ -28,6 +28,7 @@ import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.home.room.threads.list.views.ThreadListFragment import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent import org.matrix.android.sdk.flow.flow @@ -53,11 +54,43 @@ class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState } init { - observeThreadsList() + fetchAndObserveThreads() } override fun handle(action: EmptyAction) {} + /** + * Observing thread list with respect to homeserver + * capabilities + */ + private fun fetchAndObserveThreads() { + when (session.getHomeServerCapabilities().canUseThreading) { + true -> { + fetchThreadList() + observeThreadSummaries() + } + false -> observeThreadsList() + } + } + + /** + * Observing thread summaries when homeserver support + * threading + */ + private fun observeThreadSummaries() { + room?.flow() + ?.liveThreadSummaries() + ?.map { room.enhanceThreadWithEditions(it) } + ?.flowOn(room.coroutineDispatchers.io) + ?.execute { asyncThreads -> + copy(threadSummaryList = asyncThreads) + } + } + + /** + * Observing thread list when homeserver do not support + * threading + */ private fun observeThreadsList() { room?.flow() ?.liveThreadList() @@ -74,6 +107,14 @@ class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState } } + private fun fetchThreadList() { + viewModelScope.launch { + room?.fetchThreadSummaries() + } + } + + fun canHomeserverUseThreading() = session.getHomeServerCapabilities().canUseThreading + fun applyFiltering(shouldFilterThreads: Boolean) { setState { copy(shouldFilterThreads = shouldFilterThreads) diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt index 2a70a5be1e6..e08f70030bb 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt @@ -20,13 +20,14 @@ import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized import im.vector.app.features.home.room.threads.arguments.ThreadListArgs +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent data class ThreadListViewState( + val threadSummaryList: Async> = Uninitialized, val rootThreadEventList: Async> = Uninitialized, val shouldFilterThreads: Boolean = false, val roomId: String ) : MavericksState { - constructor(args: ThreadListArgs) : this(roomId = args.roomId) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt index 180e6226d00..949778629bc 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt @@ -34,9 +34,11 @@ import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.animation.TimelineItemAnimator import im.vector.app.features.home.room.threads.ThreadsActivity import im.vector.app.features.home.room.threads.arguments.ThreadListArgs +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListController import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewModel import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewState +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.MatrixItem import javax.inject.Inject @@ -111,12 +113,30 @@ class ThreadListFragment @Inject constructor( views.includeThreadListToolbar.roomToolbarThreadSubtitleTextView.text = threadListArgs.displayName } - override fun onThreadClicked(timelineEvent: TimelineEvent) { - (activity as? ThreadsActivity)?.navigateToThreadTimeline(timelineEvent) + override fun onThreadSummaryClicked(threadSummary: ThreadSummary) { + val roomThreadDetailArgs = ThreadTimelineArgs( + roomId = threadSummary.roomId, + displayName = threadSummary.rootThreadSenderInfo.displayName, + avatarUrl = threadSummary.rootThreadSenderInfo.avatarUrl, + roomEncryptionTrustLevel = null, + rootThreadEventId = threadSummary.rootEventId) + (activity as? ThreadsActivity)?.navigateToThreadTimeline(roomThreadDetailArgs) + } + + override fun onThreadListClicked(timelineEvent: TimelineEvent) { + val threadTimelineArgs = ThreadTimelineArgs( + roomId = timelineEvent.roomId, + displayName = timelineEvent.senderInfo.displayName, + avatarUrl = timelineEvent.senderInfo.avatarUrl, + roomEncryptionTrustLevel = null, + rootThreadEventId = timelineEvent.eventId) + (activity as? ThreadsActivity)?.navigateToThreadTimeline(threadTimelineArgs) } private fun renderEmptyStateIfNeeded(state: ThreadListViewState) { - val show = state.rootThreadEventList.invoke().isNullOrEmpty() - views.threadListEmptyConstraintLayout.isVisible = show + when (threadListViewModel.canHomeserverUseThreading()) { + true -> views.threadListEmptyConstraintLayout.isVisible = state.threadSummaryList.invoke().isNullOrEmpty() + false -> views.threadListEmptyConstraintLayout.isVisible = state.rootThreadEventList.invoke().isNullOrEmpty() + } } }