Merge pull request #5298 from vector-im/feature/aris/thread_live_thread_list

Live Threads
This commit is contained in:
Aris Kotsomitopoulos 2022-03-15 15:14:26 +01:00 committed by GitHub
commit e0b93c2d2c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
88 changed files with 2050 additions and 323 deletions

1
changelog.d/5230.feature Normal file
View file

@ -0,0 +1 @@
Thread timeline is now live and much faster especially for large or old threads

1
changelog.d/5232.feature Normal file
View file

@ -0,0 +1 @@
View all threads per room screen is now live when the home server supports threads

1
changelog.d/5271.sdk Normal file
View file

@ -0,0 +1 @@
Adds support for MSC3440, additional threads homeserver capabilities

View file

@ -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.model.RoomSummary
import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState 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.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.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.api.util.toOptional
@ -101,13 +102,18 @@ class FlowRoom(private val room: Room) {
return room.getLiveRoomNotificationState().asFlow() return room.getLiveRoomNotificationState().asFlow()
} }
fun liveThreadSummaries(): Flow<List<ThreadSummary>> {
return room.getAllThreadSummariesLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
room.getAllThreadSummaries()
}
}
fun liveThreadList(): Flow<List<ThreadRootEvent>> { fun liveThreadList(): Flow<List<ThreadRootEvent>> {
return room.getAllThreadsLive().asFlow() return room.getAllThreadsLive().asFlow()
.startWith(room.coroutineDispatchers.io) { .startWith(room.coroutineDispatchers.io) {
room.getAllThreads() room.getAllThreads()
} }
} }
fun liveLocalUnreadThreadList(): Flow<List<ThreadRootEvent>> { fun liveLocalUnreadThreadList(): Flow<List<ThreadRootEvent>> {
return room.getMarkedThreadNotificationsLive().asFlow() return room.getMarkedThreadNotificationsLive().asFlow()
.startWith(room.coroutineDispatchers.io) { .startWith(room.coroutineDispatchers.io) {

View file

@ -62,7 +62,11 @@ internal class ChunkEntityTest : InstrumentedTest {
val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let { val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let {
realm.copyToRealm(it) 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 chunk.timelineEvents.size shouldBeEqualTo 1
} }
} }
@ -74,8 +78,16 @@ internal class ChunkEntityTest : InstrumentedTest {
val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let { val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let {
realm.copyToRealm(it) realm.copyToRealm(it)
} }
chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap()) chunk.addTimelineEvent(
chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap()) 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 chunk.timelineEvents.size shouldBeEqualTo 1
} }
} }
@ -144,7 +156,11 @@ internal class ChunkEntityTest : InstrumentedTest {
val fakeEvent = event.toEntity(roomId, SendState.SYNCED, System.currentTimeMillis()).let { val fakeEvent = event.toEntity(roomId, SendState.SYNCED, System.currentTimeMillis()).let {
realm.copyToRealm(it) realm.copyToRealm(it)
} }
addTimelineEvent(roomId, fakeEvent, direction, emptyMap()) addTimelineEvent(
roomId = roomId,
eventEntity = fakeEvent,
direction = direction,
roomMemberContentsByUser = emptyMap())
} }
} }

View file

@ -49,5 +49,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class AggregatedRelations( data class AggregatedRelations(
@Json(name = "m.annotation") val annotations: AggregatedAnnotation? = null, @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
) )

View file

@ -201,7 +201,11 @@ data class Event(
*/ */
fun getDecryptedTextSummary(): String? { fun getDecryptedTextSummary(): String? {
if (isRedacted()) return "Message Deleted" 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 { return when {
isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text) isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text)
isFileMessage() -> "sent a file." isFileMessage() -> "sent a file."
@ -385,12 +389,12 @@ fun Event.isReply(): Boolean {
} }
fun Event.isReplyRenderedInThread(): 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 { fun Event.isEdition(): Boolean {
return getRelationContentForType(RelationType.REPLACE)?.eventId != null return getRelationContentForType(RelationType.REPLACE)?.eventId != null

View file

@ -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

View file

@ -30,7 +30,6 @@ object RelationType {
/** Lets you define an event which is a thread reply to an existing event.*/ /** Lets you define an event which is a thread reply to an existing event.*/
const val THREAD = "m.thread" 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.*/ /** Lets you define an event which adds a response to an existing event.*/
const val RESPONSE = "org.matrix.response" const val RESPONSE = "org.matrix.response"

View file

@ -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. * 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. * 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 { enum class RoomCapabilitySupport {

View file

@ -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.state.StateService
import org.matrix.android.sdk.api.session.room.tags.TagsService 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.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.timeline.TimelineService
import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.typing.TypingService
import org.matrix.android.sdk.api.session.room.uploads.UploadsService import org.matrix.android.sdk.api.session.room.uploads.UploadsService
@ -47,6 +48,7 @@ import org.matrix.android.sdk.api.util.Optional
interface Room : interface Room :
TimelineService, TimelineService,
ThreadsService, ThreadsService,
ThreadsLocalService,
SendService, SendService,
DraftService, DraftService,
ReadService, ReadService,

View file

@ -26,5 +26,6 @@ data class ReactionInfo(
@Json(name = "key") val key: String, @Json(name = "key") val key: String,
// always null for reaction // always null for reaction
@Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null, @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 ) : RelationContent

View file

@ -24,4 +24,10 @@ interface RelationContent {
val eventId: String? val eventId: String?
val inReplyTo: ReplyToContent? val inReplyTo: ReplyToContent?
val option: Int? val option: Int?
/**
* This flag indicates that the message should be rendered as a reply
* fallback, when isFallingBack = false
*/
val isFallingBack: Boolean?
} }

View file

@ -23,5 +23,8 @@ data class RelationDefaultContent(
@Json(name = "rel_type") override val type: String?, @Json(name = "rel_type") override val type: String?,
@Json(name = "event_id") override val eventId: String?, @Json(name = "event_id") override val eventId: String?,
@Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null, @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 ) : RelationContent
fun RelationDefaultContent.shouldRenderInThread(): Boolean = isFallingBack == false

View file

@ -163,13 +163,4 @@ interface RelationService {
autoMarkdown: Boolean = false, autoMarkdown: Boolean = false,
formattedText: String? = null, formattedText: String? = null,
eventReplied: TimelineEvent? = null): Cancelable? 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
} }

View file

@ -21,8 +21,5 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class ReplyToContent( data class ReplyToContent(
@Json(name = "event_id") val eventId: String? = null, @Json(name = "event_id") val eventId: String? = null
@Json(name = "render_in") val renderIn: List<String>? = null
) )
fun ReplyToContent.shouldRenderInThread(): Boolean = renderIn?.contains("m.thread") == true

View file

@ -17,51 +17,43 @@
package org.matrix.android.sdk.api.session.room.threads package org.matrix.android.sdk.api.session.room.threads
import androidx.lifecycle.LiveData 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. * This interface defines methods to interact with thread related features.
* It's implemented at the room level within the main timeline. * 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 { 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<List<TimelineEvent>> fun getAllThreadSummariesLive(): LiveData<List<ThreadSummary>>
/** /**
* 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<TimelineEvent> fun getAllThreadSummaries(): List<ThreadSummary>
/** /**
* Returns a [LiveData] list of all the marked unread threads that exists at the room level * Enhance the provided ThreadSummary[List] by adding the latest
*/
fun getMarkedThreadNotificationsLive(): LiveData<List<TimelineEvent>>
/**
* Returns a list of all the marked unread threads that exists at the room level
*/
fun getMarkedThreadNotifications(): List<TimelineEvent>
/**
* 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 * message edition for that thread
* @return the enhanced [List] with edited updates * @return the enhanced [List] with edited updates
*/ */
fun mapEventsWithEdition(threads: List<TimelineEvent>): List<TimelineEvent> fun enhanceThreadWithEditions(threads: List<ThreadSummary>): List<ThreadSummary>
/** /**
* Marks the current thread as read in local DB. * Fetch all thread replies for the specified thread using the /relations api
* note: read receipts within threads are not yet supported with the API * @param rootThreadEventId the root thread eventId
* @param rootThreadEventId the root eventId of the current thread * @param from defines the token that will fetch from that position
* @param limit defines the number of max results the api will respond with
*/ */
suspend fun markThreadAsRead(rootThreadEventId: String) suspend fun fetchThreadTimeline(rootThreadEventId: String, from: String, limit: Int)
/**
* Fetch all thread summaries for the current room using the enhanced /messages api
*/
suspend fun fetchThreadSummaries()
} }

View file

@ -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<List<TimelineEvent>>
/**
* Returns a list of all the thread root TimelineEvents that exists at the room level
*/
fun getAllThreads(): List<TimelineEvent>
/**
* Returns a [LiveData] list of all the marked unread threads that exists at the room level
*/
fun getMarkedThreadNotificationsLive(): LiveData<List<TimelineEvent>>
/**
* Returns a list of all the marked unread threads that exists at the room level
*/
fun getMarkedThreadNotifications(): List<TimelineEvent>
/**
* 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<TimelineEvent>): List<TimelineEvent>
/**
* 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)
}

View file

@ -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)

View file

@ -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())

View file

@ -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
}

View file

@ -54,6 +54,7 @@ data class TimelineEvent(
* It's not unique on the timeline as it's reset on each chunk. * It's not unique on the timeline as it's reset on each chunk.
*/ */
val displayIndex: Int, val displayIndex: Int,
var ownedByThreadChunk: Boolean = false,
val senderInfo: SenderInfo, val senderInfo: SenderInfo,
val annotations: EventAnnotationsSummary? = null, val annotations: EventAnnotationsSummary? = null,
val readReceipts: List<ReadReceipt> = emptyList() val readReceipts: List<ReadReceipt> = emptyList()

View file

@ -16,6 +16,7 @@
package org.matrix.android.sdk.api.session.threads 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 import org.matrix.android.sdk.api.session.room.sender.SenderInfo
/** /**
@ -26,7 +27,7 @@ data class ThreadDetails(
val isRootThread: Boolean = false, val isRootThread: Boolean = false,
val numberOfThreads: Int = 0, val numberOfThreads: Int = 0,
val threadSummarySenderInfo: SenderInfo? = null, val threadSummarySenderInfo: SenderInfo? = null,
val threadSummaryLatestTextMessage: String? = null, val threadSummaryLatestEvent: Event? = null,
val lastMessageTimestamp: Long? = null, val lastMessageTimestamp: Long? = null,
var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE, var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE,
val isThread: Boolean = false, val isThread: Boolean = false,

View file

@ -17,6 +17,7 @@
package org.matrix.android.sdk.api.util package org.matrix.android.sdk.api.util
import org.matrix.android.sdk.BuildConfig 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.group.model.GroupSummary
import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary 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.model.RoomSummary
@ -199,6 +200,8 @@ fun RoomMemberSummary.toMatrixItem() = MatrixItem.UserItem(userId, displayName,
fun SenderInfo.toMatrixItem() = MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl) fun SenderInfo.toMatrixItem() = MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl)
fun SenderInfo.toMatrixItemOrNull() = tryOrNull { MatrixItem.UserItem(userId, disambiguatedDisplayName, avatarUrl) }
fun SpaceChildInfo.toMatrixItem() = if (roomType == RoomType.SPACE) { fun SpaceChildInfo.toMatrixItem() = if (roomType == RoomType.SPACE) {
MatrixItem.SpaceItem(childRoomId, name ?: canonicalAlias, avatarUrl) MatrixItem.SpaceItem(childRoomId, name ?: canonicalAlias, avatarUrl)
} else { } else {

View file

@ -38,7 +38,7 @@ internal data class HomeServerVersion(
} }
companion object { 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? { internal fun parse(value: String): HomeServerVersion? {
val result = pattern.matchEntire(value) ?: return null 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_4_0 = HomeServerVersion(major = 0, minor = 4, patch = 0)
val r0_5_0 = HomeServerVersion(major = 0, minor = 5, 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 r0_6_0 = HomeServerVersion(major = 0, minor = 6, patch = 0)
val v1_3_0 = HomeServerVersion(major = 1, minor = 3, patch = 0)
} }
} }

View file

@ -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_REQUIRE_IDENTITY_SERVER = "m.require_identity_server"
private const val FEATURE_ID_ACCESS_TOKEN = "m.id_access_token" 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_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 * Return true if the SDK supports this homeserver version
@ -68,6 +70,14 @@ internal fun Versions.isLoginAndRegistrationSupportedBySdk(): Boolean {
doesServerSeparatesAddAndBind() 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 * Return true if the server support the lazy loading of room members
* *

View file

@ -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.MigrateSessionTo023
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo024 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.MigrateSessionTo025
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo026
import org.matrix.android.sdk.internal.util.Normalizer import org.matrix.android.sdk.internal.util.Normalizer
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -57,7 +58,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
override fun equals(other: Any?) = other is RealmSessionStoreMigration override fun equals(other: Any?) = other is RealmSessionStoreMigration
override fun hashCode() = 1000 override fun hashCode() = 1000
val schemaVersion = 25L val schemaVersion = 26L
override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) { override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) {
Timber.d("Migrating Realm Session from $oldVersion to $newVersion") 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 < 23) MigrateSessionTo023(realm).perform()
if (oldVersion < 24) MigrateSessionTo024(realm).perform() if (oldVersion < 24) MigrateSessionTo024(realm).perform()
if (oldVersion < 25) MigrateSessionTo025(realm).perform() if (oldVersion < 25) MigrateSessionTo025(realm).perform()
if (oldVersion < 26) MigrateSessionTo026(realm).perform()
} }
} }

View file

@ -82,17 +82,18 @@ internal fun ChunkEntity.addStateEvent(roomId: String, stateEvent: EventEntity,
internal fun ChunkEntity.addTimelineEvent(roomId: String, internal fun ChunkEntity.addTimelineEvent(roomId: String,
eventEntity: EventEntity, eventEntity: EventEntity,
direction: PaginationDirection, direction: PaginationDirection,
roomMemberContentsByUser: Map<String, RoomMemberContent?>? = null) { ownedByThreadChunk: Boolean = false,
roomMemberContentsByUser: Map<String, RoomMemberContent?>? = null): TimelineEventEntity? {
val eventId = eventEntity.eventId val eventId = eventEntity.eventId
if (timelineEvents.find(eventId) != null) { if (timelineEvents.find(eventId) != null) {
return return null
} }
val displayIndex = nextDisplayIndex(direction) val displayIndex = nextDisplayIndex(direction)
val localId = TimelineEventEntity.nextId(realm) val localId = TimelineEventEntity.nextId(realm)
val senderId = eventEntity.sender ?: "" val senderId = eventEntity.sender ?: ""
// Update RR for the sender of a new message with a dummy one // 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<TimelineEventEntity>().apply { val timelineEventEntity = realm.createObject<TimelineEventEntity>().apply {
this.localId = localId this.localId = localId
this.root = eventEntity this.root = eventEntity
@ -102,6 +103,7 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String,
?.also { it.cleanUp(eventEntity.sender) } ?.also { it.cleanUp(eventEntity.sender) }
this.readReceipts = readReceiptsSummaryEntity this.readReceipts = readReceiptsSummaryEntity
this.displayIndex = displayIndex this.displayIndex = displayIndex
this.ownedByThreadChunk = ownedByThreadChunk
val roomMemberContent = roomMemberContentsByUser?.get(senderId) val roomMemberContent = roomMemberContentsByUser?.get(senderId)
this.senderAvatar = roomMemberContent?.avatarUrl this.senderAvatar = roomMemberContent?.avatarUrl
this.senderName = roomMemberContent?.displayName this.senderName = roomMemberContent?.displayName
@ -113,9 +115,10 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String,
} }
// numberOfTimelineEvents++ // numberOfTimelineEvents++
timelineEvents.add(timelineEventEntity) timelineEvents.add(timelineEventEntity)
return timelineEventEntity
} }
private fun computeIsUnique( fun computeIsUnique(
realm: Realm, realm: Realm,
roomId: String, roomId: String,
isLastForward: Boolean, isLastForward: Boolean,

View file

@ -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.ChunkEntity
import org.matrix.android.sdk.internal.database.model.RoomEntity 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) { internal fun RoomEntity.addIfNecessary(chunkEntity: ChunkEntity) {
if (!chunks.contains(chunkEntity)) { if (!chunks.contains(chunkEntity)) {
chunks.add(chunkEntity) chunks.add(chunkEntity)
} }
} }
internal fun RoomEntity.addIfNecessary(threadSummary: ThreadSummaryEntity) {
if (!threadSummaries.contains(threadSummary)) {
threadSummaries.add(threadSummary)
}
}

View file

@ -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.where
import org.matrix.android.sdk.internal.database.query.whereRoomId import org.matrix.android.sdk.internal.database.query.whereRoomId
private typealias ThreadSummary = Pair<Int, TimelineEventEntity>? private typealias Summary = Pair<Int, TimelineEventEntity>?
/** /**
* Finds the root thread event and update it with the latest message summary along with the number * 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 * @param rootThreadEventId The root eventId that will find the number of threads
* @return A ThreadSummary containing the counted threads and the latest event message * @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 // Number of messages
val messages = TimelineEventEntity val messages = TimelineEventEntity
.whereRoomId(realm, roomId = roomId) .whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId) .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.distinct(TimelineEventEntityFields.ROOT.EVENT_ID)
.count() .count()
.toInt() .toInt()
@ -123,7 +124,7 @@ internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId:
result ?: return null result ?: return null
return ThreadSummary(messages, result) return Summary(messages, result)
} }
/** /**
@ -156,6 +157,7 @@ internal fun TimelineEventEntity.Companion.findAllThreadsForRoomId(realm: Realm,
TimelineEventEntity TimelineEventEntity
.whereRoomId(realm, roomId = roomId) .whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true) .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) .sort("${TimelineEventEntityFields.ROOT.THREAD_SUMMARY_LATEST_MESSAGE}.${TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS}", Sort.DESCENDING)
/** /**

View file

@ -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<String, RoomMemberContent?>) {
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<String, RoomMemberContent?>
) {
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<String, RoomMemberContent?>
) {
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<String, RoomMemberContent?>): TimelineEventEntity {
val roomId = roomId
val eventId = eventId
val localId = TimelineEventEntity.nextId(realm)
val senderId = sender ?: ""
val timelineEventEntity = realm.createObject<TimelineEventEntity>().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<String, RoomMemberContent?>,
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<String, RoomMemberContent?>.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<String, RoomMemberContent?>,
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> =
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<ThreadSummary>.enhanceWithEditions(realm: Realm, roomId: String): List<ThreadSummary> =
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)"
}
}
}
}

View file

@ -114,7 +114,7 @@ internal object EventMapper {
) )
}, },
threadNotificationState = eventEntity.threadNotificationState, threadNotificationState = eventEntity.threadNotificationState,
threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedTextSummary(), threadSummaryLatestEvent = eventEntity.threadSummaryLatestMessage?.root?.asDomain(),
lastMessageTimestamp = eventEntity.threadSummaryLatestMessage?.root?.originServerTs lastMessageTimestamp = eventEntity.threadSummaryLatestMessage?.root?.originServerTs
) )

View file

@ -41,7 +41,8 @@ internal object HomeServerCapabilitiesMapper {
maxUploadFileSize = entity.maxUploadFileSize, maxUploadFileSize = entity.maxUploadFileSize,
lastVersionIdentityServerSupported = entity.lastVersionIdentityServerSupported, lastVersionIdentityServerSupported = entity.lastVersionIdentityServerSupported,
defaultIdentityServerUrl = entity.defaultIdentityServerUrl, defaultIdentityServerUrl = entity.defaultIdentityServerUrl,
roomVersions = mapRoomVersion(entity.roomVersionsJson) roomVersions = mapRoomVersion(entity.roomVersionsJson),
canUseThreading = entity.canUseThreading
) )
} }

View file

@ -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
)
}
}

View file

@ -46,6 +46,7 @@ internal class TimelineEventMapper @Inject constructor(private val readReceiptsS
isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName, isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName,
avatarUrl = timelineEventEntity.senderAvatar avatarUrl = timelineEventEntity.senderAvatar
), ),
ownedByThreadChunk = timelineEventEntity.ownedByThreadChunk,
readReceipts = readReceipts readReceipts = readReceipts
?.distinctBy { ?.distinctBy {
it.roomMember it.roomMember

View file

@ -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()
}
}

View file

@ -33,7 +33,10 @@ internal open class ChunkEntity(@Index var prevToken: String? = null,
var timelineEvents: RealmList<TimelineEventEntity> = RealmList(), var timelineEvents: RealmList<TimelineEventEntity> = RealmList(),
// Only one chunk will have isLastForward == true // Only one chunk will have isLastForward == true
@Index var isLastForward: Boolean = false, @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() { ) : RealmObject() {
fun identifier() = "${prevToken}_$nextToken" fun identifier() = "${prevToken}_$nextToken"
@ -47,14 +50,32 @@ internal open class ChunkEntity(@Index var prevToken: String? = null,
companion object companion object
} }
internal fun ChunkEntity.deleteOnCascade(deleteStateEvents: Boolean, canDeleteRoot: Boolean) { internal fun ChunkEntity.deleteOnCascade(
deleteStateEvents: Boolean,
canDeleteRoot: Boolean) {
assertIsManaged() assertIsManaged()
if (deleteStateEvents) { if (deleteStateEvents) {
stateEvents.deleteAllFromRealm() stateEvents.deleteAllFromRealm()
} }
timelineEvents.clearWith { timelineEvents.clearWith {
val deleteRoot = canDeleteRoot && (it.root?.stateKey == null || deleteStateEvents) val deleteRoot = canDeleteRoot && (it.root?.stateKey == null || deleteStateEvents)
if (deleteRoot) {
room?.firstOrNull()?.removeThreadSummaryIfNeeded(it.eventId)
}
it.deleteOnCascade(deleteRoot) it.deleteOnCascade(deleteRoot)
} }
deleteFromRealm() 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()
}

View file

@ -34,14 +34,14 @@ internal open class EventEntity(@Index var eventId: String = "",
@Index var stateKey: String? = null, @Index var stateKey: String? = null,
var originServerTs: Long? = null, var originServerTs: Long? = null,
@Index var sender: String? = null, @Index var sender: String? = null,
// Can contain a serialized MatrixError // Can contain a serialized MatrixError
var sendStateDetails: String? = null, var sendStateDetails: String? = null,
var age: Long? = 0, var age: Long? = 0,
var unsignedData: String? = null, var unsignedData: String? = null,
var redacts: String? = null, var redacts: String? = null,
var decryptionResultJson: String? = null, var decryptionResultJson: String? = null,
var ageLocalTs: Long? = 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 isRootThread: Boolean = false,
@Index var rootThreadEventId: String? = null, @Index var rootThreadEventId: String? = null,
var numberOfThreads: Int = 0, var numberOfThreads: Int = 0,

View file

@ -28,7 +28,8 @@ internal open class HomeServerCapabilitiesEntity(
var maxUploadFileSize: Long = HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN, var maxUploadFileSize: Long = HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN,
var lastVersionIdentityServerSupported: Boolean = false, var lastVersionIdentityServerSupported: Boolean = false,
var defaultIdentityServerUrl: String? = null, var defaultIdentityServerUrl: String? = null,
var lastUpdatedTimestamp: Long = 0L var lastUpdatedTimestamp: Long = 0L,
var canUseThreading: Boolean = false
) : RealmObject() { ) : RealmObject() {
companion object companion object

View file

@ -20,10 +20,14 @@ import io.realm.RealmList
import io.realm.RealmObject import io.realm.RealmObject
import io.realm.annotations.PrimaryKey import io.realm.annotations.PrimaryKey
import org.matrix.android.sdk.api.session.room.model.Membership 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 = "", internal open class RoomEntity(@PrimaryKey var roomId: String = "",
var chunks: RealmList<ChunkEntity> = RealmList(), var chunks: RealmList<ChunkEntity> = RealmList(),
var sendingTimelineEvents: RealmList<TimelineEventEntity> = RealmList(), var sendingTimelineEvents: RealmList<TimelineEventEntity> = RealmList(),
var threadSummaries: RealmList<ThreadSummaryEntity> = RealmList(),
var accountData: RealmList<RoomAccountDataEntity> = RealmList() var accountData: RealmList<RoomAccountDataEntity> = RealmList()
) : RealmObject() { ) : RealmObject() {
@ -46,3 +50,10 @@ internal open class RoomEntity(@PrimaryKey var roomId: String = "",
} }
companion object companion object
} }
internal fun RoomEntity.removeThreadSummaryIfNeeded(eventId: String) {
assertIsManaged()
threadSummaries.findRootOrLatest(eventId)?.let {
threadSummaries.remove(it)
it.deleteFromRealm()
}
}

View file

@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.database.model
import io.realm.annotations.RealmModule import io.realm.annotations.RealmModule
import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntity import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntity
import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
/** /**
* Realm module for Session * Realm module for Session
@ -66,6 +67,7 @@ import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntit
RoomAccountDataEntity::class, RoomAccountDataEntity::class,
SpaceChildSummaryEntity::class, SpaceChildSummaryEntity::class,
SpaceParentSummaryEntity::class, SpaceParentSummaryEntity::class,
UserPresenceEntity::class UserPresenceEntity::class,
ThreadSummaryEntity::class
]) ])
internal class SessionRealmModule internal class SessionRealmModule

View file

@ -32,6 +32,9 @@ internal open class TimelineEventEntity(var localId: Long = 0,
var isUniqueDisplayName: Boolean = false, var isUniqueDisplayName: Boolean = false,
var senderAvatar: String? = null, var senderAvatar: String? = null,
var senderMembershipEventId: 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 var readReceipts: ReadReceiptsSummaryEntity? = null
) : RealmObject() { ) : RealmObject() {

View file

@ -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<RoomEntity>? = null
companion object
}

View file

@ -45,10 +45,22 @@ internal fun ChunkEntity.Companion.findLastForwardChunkOfRoom(realm: Realm, room
.equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true)
.findFirst() .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<String>): RealmResults<ChunkEntity> { internal fun ChunkEntity.Companion.findAllIncludingEvents(realm: Realm, eventIds: List<String>): RealmResults<ChunkEntity> {
return realm.where<ChunkEntity>() return realm.where<ChunkEntity>()
.`in`(ChunkEntityFields.TIMELINE_EVENTS.EVENT_ID, eventIds.toTypedArray()) .`in`(ChunkEntityFields.TIMELINE_EVENTS.EVENT_ID, eventIds.toTypedArray())
.isNull(ChunkEntityFields.ROOT_THREAD_EVENT_ID)
.findAll() .findAll()
} }

View file

@ -34,7 +34,7 @@ internal fun EventAnnotationsSummaryEntity.Companion.create(realm: Realm, roomId
this.roomId = roomId this.roomId = roomId
} }
// Denormalization // Denormalization
TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId).findFirst()?.let { TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId).findAll()?.forEach {
it.annotations = obj it.annotations = obj
} }
return obj return obj

View file

@ -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<ThreadSummaryEntity> {
return realm.where<ThreadSummaryEntity>()
.equalTo(ThreadSummaryEntityFields.ROOM.ROOM_ID, roomId)
}
internal fun ThreadSummaryEntity.Companion.where(realm: Realm, roomId: String, rootThreadEventId: String): RealmQuery<ThreadSummaryEntity> {
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<ThreadSummaryEntity>().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<ThreadSummaryEntity>.find(rootThreadEventId: String): ThreadSummaryEntity? {
return this.where()
.equalTo(ThreadSummaryEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.findFirst()
}
internal fun RealmList<ThreadSummaryEntity>.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()
}

View file

@ -17,9 +17,21 @@
package org.matrix.android.sdk.internal.session.filter 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.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType
import timber.log.Timber
internal object FilterFactory { 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 { fun createUploadsFilter(numberOfEvents: Int): RoomEventFilter {
return RoomEventFilter( return RoomEventFilter(
limit = numberOfEvents, limit = numberOfEvents,
@ -58,8 +70,8 @@ internal object FilterFactory {
private fun createElementTimelineFilter(): RoomEventFilter? { private fun createElementTimelineFilter(): RoomEventFilter? {
return null // RoomEventFilter().apply { return null // RoomEventFilter().apply {
// TODO Enable this for optimization // TODO Enable this for optimization
// types = listOfSupportedEventTypes.toMutableList() // types = listOfSupportedEventTypes.toMutableList()
// } // }
} }

View file

@ -52,12 +52,13 @@ data class RoomEventFilter(
* A list of relation types which must be exist pointing to the event being filtered. * 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. * If this list is absent then no filtering is done on relation types.
*/ */
@Json(name = "relation_types") val relationTypes: List<String>? = null, @Json(name = "related_by_rel_types") val relationTypes: List<String>? = null,
/** /**
* A list of senders of relations which must exist pointing to the event being filtered. * 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. * If this list is absent then no filtering is done on relation types.
*/ */
@Json(name = "relation_senders") val relationSenders: List<String>? = null, @Json(name = "related_by_senders") val relationSenders: List<String>? = null,
/** /**
* A list of room IDs to include. If this list is absent then all rooms are included. * A list of room IDs to include. If this list is absent then all rooms are included.
*/ */

View file

@ -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. * Clients should make use of this capability to determine if users need to be encouraged to upgrade their rooms.
*/ */
@Json(name = "m.room_versions") @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) @JsonClass(generateAdapter = true)

View file

@ -20,9 +20,11 @@ import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.MatrixPatterns.getDomain import org.matrix.android.sdk.api.MatrixPatterns.getDomain
import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig import org.matrix.android.sdk.api.auth.data.HomeServerConnectionConfig
import org.matrix.android.sdk.api.auth.wellknown.WellknownResult 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.extensions.orTrue
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities 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.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.auth.version.isLoginAndRegistrationSupportedBySdk
import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntity import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntity
import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.getOrCreate
@ -121,6 +123,8 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
homeServerCapabilitiesEntity.roomVersionsJson = capabilities?.roomVersions?.let { homeServerCapabilitiesEntity.roomVersionsJson = capabilities?.roomVersions?.let {
MoshiProvider.providesMoshi().adapter(RoomVersions::class.java).toJson(it) MoshiProvider.providesMoshi().adapter(RoomVersions::class.java).toJson(it)
} }
homeServerCapabilitiesEntity.canUseThreading = /* capabilities?.threads?.enabled.orFalse() || */
getVersionResult?.doesServerSupportThreads().orFalse()
} }
if (getMediaConfigResult != null) { if (getMediaConfigResult != null) {

View file

@ -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.state.StateService
import org.matrix.android.sdk.api.session.room.tags.TagsService 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.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.timeline.TimelineService
import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.typing.TypingService
import org.matrix.android.sdk.api.session.room.uploads.UploadsService 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 roomSummaryDataSource: RoomSummaryDataSource,
private val timelineService: TimelineService, private val timelineService: TimelineService,
private val threadsService: ThreadsService, private val threadsService: ThreadsService,
private val threadsLocalService: ThreadsLocalService,
private val sendService: SendService, private val sendService: SendService,
private val draftService: DraftService, private val draftService: DraftService,
private val stateService: StateService, private val stateService: StateService,
@ -80,6 +82,7 @@ internal class DefaultRoom(override val roomId: String,
Room, Room,
TimelineService by timelineService, TimelineService by timelineService,
ThreadsService by threadsService, ThreadsService by threadsService,
ThreadsLocalService by threadsLocalService,
SendService by sendService, SendService by sendService,
DraftService by draftService, DraftService by draftService,
StateService by stateService, StateService by stateService,

View file

@ -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.ReactionAggregatedSummaryEntityFields
import org.matrix.android.sdk.internal.database.model.ReferencesAggregatedSummaryEntity 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.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.create
import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.where 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() EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst()
?.let { ?.let {
TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findFirst() TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll()
?.let { tet -> tet.annotations = it } ?.forEach { tet -> tet.annotations = it }
} }
} }
@ -193,6 +194,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
handleReaction(realm, event, roomId, isLocalEcho) 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 -> { EventType.REDACTION -> {
val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() } 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 // 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, private fun handleReplace(realm: Realm,
event: Event, event: Event,
@ -332,13 +343,18 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
} }
if (!isLocalEcho) { 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) handleThreadSummaryEdition(editedEvent, replaceEvent, existingSummary?.editions)
} }
} }
/** /**
* Check if the edition is on the latest thread event, and update it accordingly * 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?, private fun handleThreadSummaryEdition(editedEvent: EventEntity?,
replaceEvent: TimelineEventEntity?, replaceEvent: TimelineEventEntity?,

View file

@ -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.Content
import org.matrix.android.sdk.api.session.events.model.Event 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.Membership
import org.matrix.android.sdk.api.session.room.model.RoomStrippedState import org.matrix.android.sdk.api.session.room.model.RoomStrippedState
import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams 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, suspend fun getRoomMessagesFrom(@Path("roomId") roomId: String,
@Query("from") from: String, @Query("from") from: String,
@Query("dir") dir: String, @Query("dir") dir: String,
@Query("limit") limit: Int, @Query("limit") limit: Int?,
@Query("filter") filter: String? @Query("filter") filter: String?
): PaginationResponse ): PaginationResponse
@ -218,7 +219,6 @@ internal interface RoomAPI {
/** /**
* Paginate relations for event based in normal topological order * Paginate relations for event based in normal topological order
*
* @param relationType filter for this relation type * @param relationType filter for this relation type
* @param eventType filter for this event type * @param eventType filter for this event type
*/ */
@ -227,9 +227,24 @@ internal interface RoomAPI {
@Path("eventId") eventId: String, @Path("eventId") eventId: String,
@Path("relationType") relationType: String, @Path("relationType") relationType: String,
@Path("eventType") eventType: String, @Path("eventType") eventType: String,
@Query("from") from: String? = null,
@Query("to") to: String? = null,
@Query("limit") limit: Int? = null @Query("limit") limit: Int? = null
): RelationsResponse ): 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. * Join the given room.
* *

View file

@ -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.summary.RoomSummaryDataSource
import org.matrix.android.sdk.internal.session.room.tags.DefaultTagsService 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.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.timeline.DefaultTimelineService
import org.matrix.android.sdk.internal.session.room.typing.DefaultTypingService import org.matrix.android.sdk.internal.session.room.typing.DefaultTypingService
import org.matrix.android.sdk.internal.session.room.uploads.DefaultUploadsService 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 roomSummaryDataSource: RoomSummaryDataSource,
private val timelineServiceFactory: DefaultTimelineService.Factory, private val timelineServiceFactory: DefaultTimelineService.Factory,
private val threadsServiceFactory: DefaultThreadsService.Factory, private val threadsServiceFactory: DefaultThreadsService.Factory,
private val threadsLocalServiceFactory: DefaultThreadsLocalService.Factory,
private val sendServiceFactory: DefaultSendService.Factory, private val sendServiceFactory: DefaultSendService.Factory,
private val draftServiceFactory: DefaultDraftService.Factory, private val draftServiceFactory: DefaultDraftService.Factory,
private val stateServiceFactory: DefaultStateService.Factory, private val stateServiceFactory: DefaultStateService.Factory,
@ -79,6 +81,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService:
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
timelineService = timelineServiceFactory.create(roomId), timelineService = timelineServiceFactory.create(roomId),
threadsService = threadsServiceFactory.create(roomId), threadsService = threadsServiceFactory.create(roomId),
threadsLocalService = threadsLocalServiceFactory.create(roomId),
sendService = sendServiceFactory.create(roomId), sendService = sendServiceFactory.create(roomId),
draftService = draftServiceFactory.create(roomId), draftService = draftServiceFactory.create(roomId),
stateService = stateServiceFactory.create(roomId), stateService = stateServiceFactory.create(roomId),

View file

@ -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.FetchEditHistoryTask
import org.matrix.android.sdk.internal.session.room.relation.FindReactionEventForUndoTask 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.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.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.relation.threads.FetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportContentTask import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportContentTask
import org.matrix.android.sdk.internal.session.room.reporting.ReportContentTask import org.matrix.android.sdk.internal.session.room.reporting.ReportContentTask
@ -294,4 +296,7 @@ internal abstract class RoomModule {
@Binds @Binds
abstract fun bindFetchThreadTimelineTask(task: DefaultFetchThreadTimelineTask): FetchThreadTimelineTask abstract fun bindFetchThreadTimelineTask(task: DefaultFetchThreadTimelineTask): FetchThreadTimelineTask
@Binds
abstract fun bindFetchThreadSummariesTask(task: DefaultFetchThreadSummariesTask): FetchThreadSummariesTask
} }

View file

@ -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.model.EventAnnotationsSummaryEntity
import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase 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.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import org.matrix.android.sdk.internal.session.room.timeline.TimelineEventDataSource 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 eventFactory: LocalEchoEventFactory,
private val findReactionEventForUndoTask: FindReactionEventForUndoTask, private val findReactionEventForUndoTask: FindReactionEventForUndoTask,
private val fetchEditHistoryTask: FetchEditHistoryTask, private val fetchEditHistoryTask: FetchEditHistoryTask,
private val fetchThreadTimelineTask: FetchThreadTimelineTask,
private val timelineEventDataSource: TimelineEventDataSource, private val timelineEventDataSource: TimelineEventDataSource,
@SessionDatabase private val monarchy: Monarchy @SessionDatabase private val monarchy: Monarchy
) : RelationService { ) : RelationService {
@ -196,10 +194,6 @@ internal class DefaultRelationService @AssistedInject constructor(
return eventSenderProcessor.postEvent(event) 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. * 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. * SendState is set to UNSENT and it's added to a the sendingTimelineEvents list of the room.

View file

@ -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<FetchThreadSummariesTask.Params, DefaultFetchThreadSummariesTask.Result> {
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<String, RoomMemberContent?>()
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
}
}

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event 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.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.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.send.SendState 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.CryptoSessionInfoProvider
import org.matrix.android.sdk.internal.crypto.DefaultCryptoService import org.matrix.android.sdk.internal.crypto.DefaultCryptoService
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult 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.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.asDomain
import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.database.mapper.toEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntity 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.EventEntity
import org.matrix.android.sdk.internal.database.model.EventInsertType 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.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.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.getOrCreate
import org.matrix.android.sdk.internal.database.query.getOrNull import org.matrix.android.sdk.internal.database.query.getOrNull
import org.matrix.android.sdk.internal.database.query.where 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.network.executeRequest
import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent 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.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.session.room.timeline.PaginationDirection
import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction import org.matrix.android.sdk.internal.util.awaitTransaction
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
internal interface FetchThreadTimelineTask : Task<FetchThreadTimelineTask.Params, Boolean> { /***
* 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<FetchThreadTimelineTask.Params, DefaultFetchThreadTimelineTask.Result> {
data class Params( data class Params(
val roomId: String, 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 private val cryptoService: DefaultCryptoService
) : FetchThreadTimelineTask { ) : FetchThreadTimelineTask {
override suspend fun execute(params: FetchThreadTimelineTask.Params): Boolean { enum class Result {
val isRoomEncrypted = cryptoSessionInfoProvider.isRoomEncrypted(params.roomId) SHOULD_FETCH_MORE,
REACHED_END,
SUCCESS
}
override suspend fun execute(params: FetchThreadTimelineTask.Params): Result {
val response = executeRequest(globalErrorReceiver) { val response = executeRequest(globalErrorReceiver) {
roomAPI.getRelations( roomAPI.getThreadsRelations(
roomId = params.roomId, roomId = params.roomId,
eventId = params.rootThreadEventId, eventId = params.rootThreadEventId,
relationType = RelationType.IO_THREAD, from = params.from,
eventType = if (isRoomEncrypted) EventType.ENCRYPTED else EventType.MESSAGE, limit = params.limit
limit = 2000
) )
} }
val threadList = response.chunks + listOfNotNull(response.originalEvent) Timber.i("###THREADS FetchThreadTimelineTask Fetched size:${response.chunks.size} nextBatch:${response.nextBatch} ")
return handleRelationsResponse(response, params)
}
return storeNewEventsIfNeeded(threadList, params.roomId) 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
}
threadChunk.prevToken = response.nextBatch
val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>()
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)
}
}
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
/**
* If we don't have any new state on this user, get it from db
*/
private fun HashMap<String, RoomMemberContent?>.addSenderState(realm: Realm, roomId: String, senderId: String) {
getOrPut(senderId) {
CurrentStateEventEntity
.getOrNull(realm, roomId, senderId, EventType.STATE_ROOM_MEMBER)
?.root?.asDomain()
?.getFixedRoomMemberContent()
}
} }
/** /**
* Store new events if they are not already received, and returns weather or not, * Create an EventEntity to be added in the TimelineEventEntity
* 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<Event>, roomId: String): Boolean { private fun createEventEntity(roomId: String, event: Event, realm: Realm): EventEntity {
var eventsSkipped = 0 val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it }
monarchy return event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION)
.awaitTransaction { realm ->
val chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId)
val optimizedThreadSummaryMap = hashMapOf<String, EventEntity>()
val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>()
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
}
}
optimizedThreadSummaryMap.updateThreadSummaryIfNeeded(
roomId = roomId,
realm = realm,
currentUserId = userId,
shouldUpdateNotifications = false
)
}
Timber.i("----> size: ${threadList.size} | skipped: $eventsSkipped | threads: ${threadList.map { it.eventId }}")
return eventsSkipped == threadList.size
} }
/** /**
* Invoke the event decryption mechanism for a specific event * Invoke the event decryption mechanism for a specific event
*/ */
private suspend fun decryptIfNeeded(event: Event, roomId: String) { private suspend fun decryptIfNeeded(event: Event, roomId: String) {
try { try {
// Event from sync does not have roomId, so add it to the event first // Event from sync does not have roomId, so add it to the event first

View file

@ -341,8 +341,9 @@ internal class LocalEchoEventFactory @Inject constructor(
url = attachment.queryUri.toString(), url = attachment.queryUri.toString(),
relatesTo = rootThreadEventId?.let { relatesTo = rootThreadEventId?.let {
RelationDefaultContent( RelationDefaultContent(
type = RelationType.IO_THREAD, type = RelationType.THREAD,
eventId = it, eventId = it,
isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
) )
} }
@ -384,8 +385,9 @@ internal class LocalEchoEventFactory @Inject constructor(
url = attachment.queryUri.toString(), url = attachment.queryUri.toString(),
relatesTo = rootThreadEventId?.let { relatesTo = rootThreadEventId?.let {
RelationDefaultContent( RelationDefaultContent(
type = RelationType.IO_THREAD, type = RelationType.THREAD,
eventId = it, eventId = it,
isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
) )
} }
@ -414,8 +416,9 @@ internal class LocalEchoEventFactory @Inject constructor(
voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap(), voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap(),
relatesTo = rootThreadEventId?.let { relatesTo = rootThreadEventId?.let {
RelationDefaultContent( RelationDefaultContent(
type = RelationType.IO_THREAD, type = RelationType.THREAD,
eventId = it, eventId = it,
isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
) )
} }
@ -434,8 +437,9 @@ internal class LocalEchoEventFactory @Inject constructor(
url = attachment.queryUri.toString(), url = attachment.queryUri.toString(),
relatesTo = rootThreadEventId?.let { relatesTo = rootThreadEventId?.let {
RelationDefaultContent( RelationDefaultContent(
type = RelationType.IO_THREAD, type = RelationType.THREAD,
eventId = it, eventId = it,
isFallingBack = true,
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it))
) )
} }
@ -467,7 +471,7 @@ internal class LocalEchoEventFactory @Inject constructor(
private fun enhanceStickerIfNeeded(type: String, content: Content?): Content? { private fun enhanceStickerIfNeeded(type: String, content: Content?): Content? {
var newContent: Content? = null var newContent: Content? = null
if (type == EventType.STICKER) { if (type == EventType.STICKER) {
val isThread = (content.toModel<MessageStickerContent>())?.relatesTo?.type == RelationType.IO_THREAD val isThread = (content.toModel<MessageStickerContent>())?.relatesTo?.type == RelationType.THREAD
val rootThreadEventId = (content.toModel<MessageStickerContent>())?.relatesTo?.eventId val rootThreadEventId = (content.toModel<MessageStickerContent>())?.relatesTo?.eventId
if (isThread && rootThreadEventId != null) { if (isThread && rootThreadEventId != null) {
val newRelationalDefaultContent = (content.toModel<MessageStickerContent>())?.relatesTo?.copy( val newRelationalDefaultContent = (content.toModel<MessageStickerContent>())?.relatesTo?.copy(
@ -548,7 +552,7 @@ internal class LocalEchoEventFactory @Inject constructor(
relatesTo = generateReplyRelationContent( relatesTo = generateReplyRelationContent(
eventId = eventId, eventId = eventId,
rootThreadEventId = rootThreadEventId, rootThreadEventId = rootThreadEventId,
showAsReply = showInThread)) showInThread = showInThread))
return createMessageEvent(roomId, content) return createMessageEvent(roomId, content)
} }
@ -558,18 +562,20 @@ internal class LocalEchoEventFactory @Inject constructor(
* "m.relates_to": { * "m.relates_to": {
* "rel_type": "m.thread", * "rel_type": "m.thread",
* "event_id": "$thread_root", * "event_id": "$thread_root",
* "is_falling_back": false,
* "m.in_reply_to": { * "m.in_reply_to": {
* "event_id": "$event_target", * "event_id": "$event_target"
* "render_in": ["m.thread"] * }
* } * }
* }
*/ */
private fun generateReplyRelationContent(eventId: String, rootThreadEventId: String? = null, showAsReply: Boolean): RelationDefaultContent = private fun generateReplyRelationContent(eventId: String, rootThreadEventId: String? = null, showInThread: Boolean): RelationDefaultContent =
rootThreadEventId?.let { rootThreadEventId?.let {
RelationDefaultContent( RelationDefaultContent(
type = RelationType.IO_THREAD, type = RelationType.THREAD,
eventId = it, 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)) } ?: RelationDefaultContent(null, null, ReplyToContent(eventId = eventId))
private fun buildFormattedReply(permalink: String, userLink: String, userId: String, bodyFormatted: String, newBodyFormatted: String): String { private fun buildFormattedReply(permalink: String, userLink: String, userId: String, bodyFormatted: String, newBodyFormatted: String): String {

View file

@ -58,8 +58,9 @@ fun TextContent.toThreadTextContent(
format = MessageFormat.FORMAT_MATRIX_HTML.takeIf { formattedText != null }, format = MessageFormat.FORMAT_MATRIX_HTML.takeIf { formattedText != null },
body = text, body = text,
relatesTo = RelationDefaultContent( relatesTo = RelationDefaultContent(
type = RelationType.IO_THREAD, type = RelationType.THREAD,
eventId = rootThreadEventId, eventId = rootThreadEventId,
isFallingBack = true,
inReplyTo = ReplyToContent( inReplyTo = ReplyToContent(
eventId = latestThreadEventId eventId = latestThreadEventId
)), )),

View file

@ -23,25 +23,25 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.realm.Realm import io.realm.Realm
import org.matrix.android.sdk.api.session.room.threads.ThreadsService 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.room.threads.model.ThreadSummary
import org.matrix.android.sdk.api.session.threads.ThreadNotificationState import org.matrix.android.sdk.internal.database.helper.enhanceWithEditions
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.findAllThreadsForRoomId
import org.matrix.android.sdk.internal.database.helper.isUserParticipatingInThread import org.matrix.android.sdk.internal.database.mapper.ThreadSummaryMapper
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.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
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.SessionDatabase
import org.matrix.android.sdk.internal.di.UserId 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( internal class DefaultThreadsService @AssistedInject constructor(
@Assisted private val roomId: String, @Assisted private val roomId: String,
@UserId private val userId: String, @UserId private val userId: String,
private val fetchThreadTimelineTask: FetchThreadTimelineTask,
private val fetchThreadSummariesTask: FetchThreadSummariesTask,
@SessionDatabase private val monarchy: Monarchy, @SessionDatabase private val monarchy: Monarchy,
private val timelineEventMapper: TimelineEventMapper, private val timelineEventMapper: TimelineEventMapper,
private val threadSummaryMapper: ThreadSummaryMapper
) : ThreadsService { ) : ThreadsService {
@AssistedFactory @AssistedFactory
@ -49,55 +49,40 @@ internal class DefaultThreadsService @AssistedInject constructor(
fun create(roomId: String): DefaultThreadsService fun create(roomId: String): DefaultThreadsService
} }
override fun getMarkedThreadNotificationsLive(): LiveData<List<TimelineEvent>> { override fun getAllThreadSummariesLive(): LiveData<List<ThreadSummary>> {
return monarchy.findAllMappedWithChanges( return monarchy.findAllMappedWithChanges(
{ TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, { ThreadSummaryEntity.findAllThreadsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) } {
threadSummaryMapper.map(it)
}
) )
} }
override fun getMarkedThreadNotifications(): List<TimelineEvent> { override fun getAllThreadSummaries(): List<ThreadSummary> {
return monarchy.fetchAllMappedSync( return monarchy.fetchAllMappedSync(
{ TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, { ThreadSummaryEntity.findAllThreadsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) } { threadSummaryMapper.map(it) }
) )
} }
override fun getAllThreadsLive(): LiveData<List<TimelineEvent>> { override fun enhanceThreadWithEditions(threads: List<ThreadSummary>): List<ThreadSummary> {
return monarchy.findAllMappedWithChanges(
{ TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
override fun getAllThreads(): List<TimelineEvent> {
return monarchy.fetchAllMappedSync(
{ TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
override fun isUserParticipatingInThread(rootThreadEventId: String): Boolean {
return Realm.getInstance(monarchy.realmConfiguration).use { return Realm.getInstance(monarchy.realmConfiguration).use {
TimelineEventEntity.isUserParticipatingInThread( threads.enhanceWithEditions(it, roomId)
realm = it,
roomId = roomId,
rootThreadEventId = rootThreadEventId,
senderId = userId)
} }
} }
override fun mapEventsWithEdition(threads: List<TimelineEvent>): List<TimelineEvent> { override suspend fun fetchThreadTimeline(rootThreadEventId: String, from: String, limit: Int) {
return Realm.getInstance(monarchy.realmConfiguration).use { fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params(
threads.mapEventsWithEdition(it, roomId) roomId = roomId,
} rootThreadEventId = rootThreadEventId,
from = from,
limit = limit
))
} }
override suspend fun markThreadAsRead(rootThreadEventId: String) { override suspend fun fetchThreadSummaries() {
monarchy.awaitTransaction { fetchThreadSummariesTask.execute(FetchThreadSummariesTask.Params(
EventEntity.where( roomId = roomId
realm = it, ))
eventId = rootThreadEventId).findFirst()?.threadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE
}
} }
} }

View file

@ -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<List<TimelineEvent>> {
return monarchy.findAllMappedWithChanges(
{ TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
override fun getMarkedThreadNotifications(): List<TimelineEvent> {
return monarchy.fetchAllMappedSync(
{ TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
override fun getAllThreadsLive(): LiveData<List<TimelineEvent>> {
return monarchy.findAllMappedWithChanges(
{ TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
{ timelineEventMapper.map(it) }
)
}
override fun getAllThreads(): List<TimelineEvent> {
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<TimelineEvent>): List<TimelineEvent> {
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
}
}
}

View file

@ -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.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper 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.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.ReadReceiptHandler
import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler
import org.matrix.android.sdk.internal.task.SemaphoreCoroutineSequencer import org.matrix.android.sdk.internal.task.SemaphoreCoroutineSequencer
@ -58,6 +59,7 @@ internal class DefaultTimeline(private val roomId: String,
paginationTask: PaginationTask, paginationTask: PaginationTask,
getEventTask: GetContextOfEventTask, getEventTask: GetContextOfEventTask,
fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, fetchTokenAndPaginateTask: FetchTokenAndPaginateTask,
fetchThreadTimelineTask: FetchThreadTimelineTask,
timelineEventMapper: TimelineEventMapper, timelineEventMapper: TimelineEventMapper,
timelineInput: TimelineInput, timelineInput: TimelineInput,
threadsAwarenessHandler: ThreadsAwarenessHandler, threadsAwarenessHandler: ThreadsAwarenessHandler,
@ -89,7 +91,9 @@ internal class DefaultTimeline(private val roomId: String,
realm = backgroundRealm, realm = backgroundRealm,
eventDecryptor = eventDecryptor, eventDecryptor = eventDecryptor,
paginationTask = paginationTask, paginationTask = paginationTask,
realmConfiguration = realmConfiguration,
fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, fetchTokenAndPaginateTask = fetchTokenAndPaginateTask,
fetchThreadTimelineTask = fetchThreadTimelineTask,
getContextOfEventTask = getEventTask, getContextOfEventTask = getEventTask,
timelineInput = timelineInput, timelineInput = timelineInput,
timelineEventMapper = timelineEventMapper, timelineEventMapper = timelineEventMapper,
@ -297,7 +301,13 @@ internal class DefaultTimeline(private val roomId: String,
Timber.v("Post snapshot of ${snapshot.size} events") Timber.v("Post snapshot of ${snapshot.size} events")
withContext(coroutineDispatchers.main) { withContext(coroutineDispatchers.main) {
listeners.forEach { 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) }
}
} }
} }
} }

View file

@ -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.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.di.SessionDatabase 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.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.ReadReceiptHandler
import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler 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 eventDecryptor: TimelineEventDecryptor,
private val paginationTask: PaginationTask, private val paginationTask: PaginationTask,
private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask,
private val fetchThreadTimelineTask: FetchThreadTimelineTask,
private val timelineEventMapper: TimelineEventMapper, private val timelineEventMapper: TimelineEventMapper,
private val loadRoomMembersTask: LoadRoomMembersTask, private val loadRoomMembersTask: LoadRoomMembersTask,
private val threadsAwarenessHandler: ThreadsAwarenessHandler, private val threadsAwarenessHandler: ThreadsAwarenessHandler,
@ -64,10 +66,11 @@ internal class DefaultTimelineService @AssistedInject constructor(
realmConfiguration = monarchy.realmConfiguration, realmConfiguration = monarchy.realmConfiguration,
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
paginationTask = paginationTask, paginationTask = paginationTask,
fetchTokenAndPaginateTask = fetchTokenAndPaginateTask,
timelineEventMapper = timelineEventMapper, timelineEventMapper = timelineEventMapper,
timelineInput = timelineInput, timelineInput = timelineInput,
eventDecryptor = eventDecryptor, eventDecryptor = eventDecryptor,
fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, fetchThreadTimelineTask = fetchThreadTimelineTask,
loadRoomMembersTask = loadRoomMembersTask, loadRoomMembersTask = loadRoomMembersTask,
readReceiptHandler = readReceiptHandler, readReceiptHandler = readReceiptHandler,
getEventTask = contextOfEventTask, getEventTask = contextOfEventTask,

View file

@ -19,20 +19,28 @@ package org.matrix.android.sdk.internal.session.room.timeline
import io.realm.OrderedCollectionChangeSet import io.realm.OrderedCollectionChangeSet
import io.realm.OrderedRealmCollectionChangeListener import io.realm.OrderedRealmCollectionChangeListener
import io.realm.Realm import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.RealmResults import io.realm.RealmResults
import io.realm.kotlin.createObject
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import org.matrix.android.sdk.api.extensions.orFalse 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.send.SendState
import org.matrix.android.sdk.api.session.room.timeline.Timeline 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.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings 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.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper 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.ChunkEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntityFields 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.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.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 org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler
import timber.log.Timber
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
/** /**
@ -76,6 +84,8 @@ internal class LoadTimelineStrategy(
val realm: AtomicReference<Realm>, val realm: AtomicReference<Realm>,
val eventDecryptor: TimelineEventDecryptor, val eventDecryptor: TimelineEventDecryptor,
val paginationTask: PaginationTask, val paginationTask: PaginationTask,
val realmConfiguration: RealmConfiguration,
val fetchThreadTimelineTask: FetchThreadTimelineTask,
val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask,
val getContextOfEventTask: GetContextOfEventTask, val getContextOfEventTask: GetContextOfEventTask,
val timelineInput: TimelineInput, val timelineInput: TimelineInput,
@ -90,7 +100,6 @@ internal class LoadTimelineStrategy(
private var getContextLatch: CompletableDeferred<Unit>? = null private var getContextLatch: CompletableDeferred<Unit>? = null
private var chunkEntity: RealmResults<ChunkEntity>? = null private var chunkEntity: RealmResults<ChunkEntity>? = null
private var timelineChunk: TimelineChunk? = null private var timelineChunk: TimelineChunk? = null
private val chunkEntityListener = OrderedRealmCollectionChangeListener { _: RealmResults<ChunkEntity>, changeSet: OrderedCollectionChangeSet -> private val chunkEntityListener = OrderedRealmCollectionChangeListener { _: RealmResults<ChunkEntity>, changeSet: OrderedCollectionChangeSet ->
// Can be call either when you open a permalink on an unknown event // Can be call either when you open a permalink on an unknown event
// or when there is a gap in the timeline. // or when there is a gap in the timeline.
@ -170,6 +179,9 @@ internal class LoadTimelineStrategy(
getContextLatch?.cancel() getContextLatch?.cancel()
chunkEntity = null chunkEntity = null
timelineChunk = 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 { suspend fun loadMore(count: Int, direction: Timeline.Direction, fetchOnServerIfNeeded: Boolean = true): LoadMoreResult {
@ -185,6 +197,9 @@ internal class LoadTimelineStrategy(
return LoadMoreResult.FAILURE return LoadMoreResult.FAILURE
} }
} }
if (mode is Mode.Thread) {
return timelineChunk?.loadMoreThread(count) ?: LoadMoreResult.FAILURE
}
return timelineChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE return timelineChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE
} }
@ -201,7 +216,7 @@ internal class LoadTimelineStrategy(
} }
private fun buildSendingEvents(): List<TimelineEvent> { private fun buildSendingEvents(): List<TimelineEvent> {
return if (hasReachedLastForward()) { return if (hasReachedLastForward() || mode is Mode.Thread) {
sendingEventsDataSource.buildSendingEvents() sendingEventsDataSource.buildSendingEvents()
} else { } else {
emptyList() emptyList()
@ -219,13 +234,47 @@ internal class LoadTimelineStrategy(
ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId)) ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId))
} }
is Mode.Thread -> { is Mode.Thread -> {
recreateThreadChunkEntity(realm, mode.rootThreadEventId)
ChunkEntity.where(realm, roomId) 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() .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<ChunkEntity>().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 { private fun hasReachedLastForward(): Boolean {
return timelineChunk?.hasReachedLastForward().orFalse() return timelineChunk?.hasReachedLastForward().orFalse()
} }
@ -237,8 +286,10 @@ internal class LoadTimelineStrategy(
timelineSettings = dependencies.timelineSettings, timelineSettings = dependencies.timelineSettings,
roomId = roomId, roomId = roomId,
timelineId = timelineId, timelineId = timelineId,
fetchThreadTimelineTask = dependencies.fetchThreadTimelineTask,
eventDecryptor = dependencies.eventDecryptor, eventDecryptor = dependencies.eventDecryptor,
paginationTask = dependencies.paginationTask, paginationTask = dependencies.paginationTask,
realmConfiguration = dependencies.realmConfiguration,
fetchTokenAndPaginateTask = dependencies.fetchTokenAndPaginateTask, fetchTokenAndPaginateTask = dependencies.fetchTokenAndPaginateTask,
timelineEventMapper = dependencies.timelineEventMapper, timelineEventMapper = dependencies.timelineEventMapper,
uiEchoManager = uiEchoManager, uiEchoManager = uiEchoManager,

View file

@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room.timeline
import io.realm.OrderedCollectionChangeSet import io.realm.OrderedCollectionChangeSet
import io.realm.OrderedRealmCollectionChangeListener import io.realm.OrderedRealmCollectionChangeListener
import io.realm.RealmConfiguration
import io.realm.RealmObjectChangeListener import io.realm.RealmObjectChangeListener
import io.realm.RealmQuery import io.realm.RealmQuery
import io.realm.RealmResults 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.ChunkEntityFields
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity 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.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 org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler
import timber.log.Timber import timber.log.Timber
import java.util.Collections import java.util.Collections
@ -50,8 +53,10 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
private val timelineSettings: TimelineSettings, private val timelineSettings: TimelineSettings,
private val roomId: String, private val roomId: String,
private val timelineId: String, private val timelineId: String,
private val fetchThreadTimelineTask: FetchThreadTimelineTask,
private val eventDecryptor: TimelineEventDecryptor, private val eventDecryptor: TimelineEventDecryptor,
private val paginationTask: PaginationTask, private val paginationTask: PaginationTask,
private val realmConfiguration: RealmConfiguration,
private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask, private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask,
private val timelineEventMapper: TimelineEventMapper, private val timelineEventMapper: TimelineEventMapper,
private val uiEchoManager: UIEchoManager? = null, private val uiEchoManager: UIEchoManager? = null,
@ -141,6 +146,9 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
val loadFromStorage = loadFromStorage(count, direction).also { val loadFromStorage = loadFromStorage(count, direction).also {
logLoadedFromStorage(it, direction) logLoadedFromStorage(it, direction)
} }
if (loadFromStorage.numberOfEvents == 6) {
Timber.i("here")
}
val offsetCount = count - loadFromStorage.numberOfEvents 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 { private suspend fun delegateLoadMore(fetchFromServerIfNeeded: Boolean, offsetCount: Int, direction: Timeline.Direction): LoadMoreResult {
return if (direction == Timeline.Direction.FORWARDS) { return if (direction == Timeline.Direction.FORWARDS) {
val nextChunkEntity = chunkEntity.nextChunk 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 { private fun getOffsetIndex(): Int {
var offset = 0 var offset = 0
var currentNextChunk = nextChunk var currentNextChunk = nextChunk
@ -454,6 +493,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
} }
} }
} }
if (insertions.isNotEmpty() || modifications.isNotEmpty()) { if (insertions.isNotEmpty() || modifications.isNotEmpty()) {
onBuiltEvents(true) onBuiltEvents(true)
} }
@ -487,6 +527,8 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
timelineId = timelineId, timelineId = timelineId,
eventDecryptor = eventDecryptor, eventDecryptor = eventDecryptor,
paginationTask = paginationTask, paginationTask = paginationTask,
realmConfiguration = realmConfiguration,
fetchThreadTimelineTask = fetchThreadTimelineTask,
fetchTokenAndPaginateTask = fetchTokenAndPaginateTask, fetchTokenAndPaginateTask = fetchTokenAndPaginateTask,
timelineEventMapper = timelineEventMapper, timelineEventMapper = timelineEventMapper,
uiEchoManager = uiEchoManager, uiEchoManager = uiEchoManager,
@ -538,7 +580,6 @@ private fun ChunkEntity.sortedTimelineEvents(rootThreadEventId: String?): RealmR
.or() .or()
.equalTo(TimelineEventEntityFields.ROOT.EVENT_ID, rootThreadEventId) .equalTo(TimelineEventEntityFields.ROOT.EVENT_ID, rootThreadEventId)
.endGroup() .endGroup()
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
.findAll() .findAll()
} }
} }

View file

@ -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.EventInsertType
import org.matrix.android.sdk.internal.database.model.RoomEntity 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.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.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.database.query.create import org.matrix.android.sdk.internal.database.query.create
import org.matrix.android.sdk.internal.database.query.find 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. * Insert Chunk in DB, and eventually link next and previous chunk in db.
*/ */
internal class TokenChunkEventPersistor @Inject constructor( internal class TokenChunkEventPersistor @Inject constructor(
@SessionDatabase private val monarchy: Monarchy, @SessionDatabase private val monarchy: Monarchy,
@UserId private val userId: String, @UserId private val userId: String,
private val lightweightSettingsStorage: LightweightSettingsStorage, private val lightweightSettingsStorage: LightweightSettingsStorage,
private val liveEventManager: Lazy<StreamEventsManager>) { private val liveEventManager: Lazy<StreamEventsManager>) {
enum class Result { enum class Result {
SHOULD_FETCH_MORE, SHOULD_FETCH_MORE,
@ -145,9 +146,12 @@ internal class TokenChunkEventPersistor @Inject constructor(
if (event.eventId == null || event.senderId == null) { if (event.eventId == null || event.senderId == null) {
return@forEach 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 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 // If it exists, we want to stop here, just link the prevChunk
val existingChunk = existingTimelineEvent?.chunk?.firstOrNull() val existingChunk = existingTimelineEvent?.chunk?.firstOrNull()
if (existingChunk != null) { if (existingChunk != null) {
@ -173,7 +177,7 @@ internal class TokenChunkEventPersistor @Inject constructor(
return@processTimelineEvents return@processTimelineEvents
} }
val ageLocalTs = event.unsignedData?.age?.let { now - it } 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) { if (event.type == EventType.STATE_ROOM_MEMBER && event.stateKey != null) {
val contentToUse = if (direction == PaginationDirection.BACKWARDS) { val contentToUse = if (direction == PaginationDirection.BACKWARDS) {
event.prevContent event.prevContent
@ -183,7 +187,11 @@ internal class TokenChunkEventPersistor @Inject constructor(
roomMemberContentsByUser[event.stateKey] = contentToUse.toModel<RoomMemberContent>() roomMemberContentsByUser[event.stateKey] = contentToUse.toModel<RoomMemberContent>()
} }
liveEventManager.get().dispatchPaginatedEventReceived(event, roomId) liveEventManager.get().dispatchPaginatedEventReceived(event, roomId)
currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser) currentChunk.addTimelineEvent(
roomId = roomId,
eventEntity = eventEntity,
direction = direction,
roomMemberContentsByUser = roomMemberContentsByUser)
if (lightweightSettingsStorage.areThreadMessagesEnabled()) { if (lightweightSettingsStorage.areThreadMessagesEnabled()) {
eventEntity.rootThreadEventId?.let { eventEntity.rootThreadEventId?.let {
// This is a thread event // This is a thread event

View file

@ -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.Event
import org.matrix.android.sdk.api.session.events.model.EventType 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.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.initsync.InitSyncStep
import org.matrix.android.sdk.api.session.room.model.Membership 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.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.send.SendState 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.InvitedRoomSync
import org.matrix.android.sdk.api.session.sync.model.LazyRoomSyncEphemeral import org.matrix.android.sdk.api.session.sync.model.LazyRoomSyncEphemeral
import org.matrix.android.sdk.api.session.sync.model.RoomSync 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.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.helper.addIfNecessary 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.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.helper.updateThreadSummaryIfNeeded
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.asDomain 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.EventInsertType
import org.matrix.android.sdk.internal.database.model.RoomEntity 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.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.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.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.database.query.find 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.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.getOrCreate
import org.matrix.android.sdk.internal.database.query.getOrNull import org.matrix.android.sdk.internal.database.query.getOrNull
import org.matrix.android.sdk.internal.database.query.where 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 threadsAwarenessHandler: ThreadsAwarenessHandler,
private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource,
@UserId private val userId: String, @UserId private val userId: String,
private val homeServerCapabilitiesService: HomeServerCapabilitiesService,
private val lightweightSettingsStorage: LightweightSettingsStorage, private val lightweightSettingsStorage: LightweightSettingsStorage,
private val timelineInput: TimelineInput, private val timelineInput: TimelineInput,
private val liveEventService: Lazy<StreamEventsManager>) { private val liveEventService: Lazy<StreamEventsManager>) {
@ -95,11 +102,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
data class LEFT(val data: Map<String, RoomSync>) : HandlingStrategy() data class LEFT(val data: Map<String, RoomSync>) : HandlingStrategy()
} }
fun handle(realm: Realm, suspend fun handle(realm: Realm,
roomsSyncResponse: RoomsSyncResponse, roomsSyncResponse: RoomsSyncResponse,
isInitialSync: Boolean, isInitialSync: Boolean,
aggregator: SyncResponsePostTreatmentAggregator, aggregator: SyncResponsePostTreatmentAggregator,
reporter: ProgressReporter? = null) { reporter: ProgressReporter? = null) {
Timber.v("Execute transaction from $this") Timber.v("Execute transaction from $this")
handleRoomSync(realm, HandlingStrategy.JOINED(roomsSyncResponse.join), isInitialSync, aggregator, reporter) handleRoomSync(realm, HandlingStrategy.JOINED(roomsSyncResponse.join), isInitialSync, aggregator, reporter)
handleRoomSync(realm, HandlingStrategy.INVITED(roomsSyncResponse.invite), 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 METHODS *****************************************************************************
private fun handleRoomSync(realm: Realm, private suspend fun handleRoomSync(realm: Realm,
handlingStrategy: HandlingStrategy, handlingStrategy: HandlingStrategy,
isInitialSync: Boolean, isInitialSync: Boolean,
aggregator: SyncResponsePostTreatmentAggregator, aggregator: SyncResponsePostTreatmentAggregator,
reporter: ProgressReporter?) { reporter: ProgressReporter?) {
val insertType = if (isInitialSync) { val insertType = if (isInitialSync) {
EventInsertType.INITIAL_SYNC EventInsertType.INITIAL_SYNC
} else { } else {
@ -151,11 +158,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
realm.insertOrUpdate(rooms) realm.insertOrUpdate(rooms)
} }
private fun insertJoinRoomsFromInitSync(realm: Realm, private suspend fun insertJoinRoomsFromInitSync(realm: Realm,
handlingStrategy: HandlingStrategy.JOINED, handlingStrategy: HandlingStrategy.JOINED,
syncLocalTimeStampMillis: Long, syncLocalTimeStampMillis: Long,
aggregator: SyncResponsePostTreatmentAggregator, aggregator: SyncResponsePostTreatmentAggregator,
reporter: ProgressReporter?) { reporter: ProgressReporter?) {
val bestChunkSize = computeBestChunkSize( val bestChunkSize = computeBestChunkSize(
listSize = handlingStrategy.data.keys.size, listSize = handlingStrategy.data.keys.size,
limit = (initialSyncStrategy as? InitialSyncStrategy.Optimized)?.maxRoomsToInsert ?: Int.MAX_VALUE 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, private suspend fun handleJoinedRoom(realm: Realm,
roomId: String, roomId: String,
roomSync: RoomSync, roomSync: RoomSync,
insertType: EventInsertType, insertType: EventInsertType,
syncLocalTimestampMillis: Long, syncLocalTimestampMillis: Long,
aggregator: SyncResponsePostTreatmentAggregator): RoomEntity { aggregator: SyncResponsePostTreatmentAggregator): RoomEntity {
Timber.v("Handle join sync for room $roomId") Timber.v("Handle join sync for room $roomId")
val ephemeralResult = (roomSync.ephemeral as? LazyRoomSyncEphemeral.Parsed) val ephemeralResult = (roomSync.ephemeral as? LazyRoomSyncEphemeral.Parsed)
@ -344,15 +351,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
return roomEntity return roomEntity
} }
private fun handleTimelineEvents(realm: Realm, private suspend fun handleTimelineEvents(realm: Realm,
roomId: String, roomId: String,
roomEntity: RoomEntity, roomEntity: RoomEntity,
eventList: List<Event>, eventList: List<Event>,
prevToken: String? = null, prevToken: String? = null,
isLimited: Boolean = true, isLimited: Boolean = true,
insertType: EventInsertType, insertType: EventInsertType,
syncLocalTimestampMillis: Long, syncLocalTimestampMillis: Long,
aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity { aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity {
val lastChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomEntity.roomId) val lastChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomEntity.roomId)
if (isLimited && lastChunk != null) { if (isLimited && lastChunk != null) {
lastChunk.deleteOnCascade(deleteStateEvents = false, canDeleteRoot = true) lastChunk.deleteOnCascade(deleteStateEvents = false, canDeleteRoot = true)
@ -409,11 +416,28 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
rootStateEvent?.asDomain()?.getFixedRoomMemberContent() 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()) { if (lightweightSettingsStorage.areThreadMessagesEnabled()) {
eventEntity.rootThreadEventId?.let { eventEntity.rootThreadEventId?.let {
// This is a thread event // This is a thread event
optimizedThreadSummaryMap[it] = eventEntity 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 { } ?: run {
// This is a normal event or a root thread one // This is a normal event or a root thread one
optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity
@ -458,6 +482,28 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
return chunkEntity 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) { private suspend fun decryptIfNeeded(event: Event, roomId: String) {
try { try {
// Event from sync does not have roomId, so add it to the event first // Event from sync does not have roomId, so add it to the event first

View file

@ -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.MessageRelationContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent 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.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.room.send.SendState
import org.matrix.android.sdk.api.session.sync.model.SyncResponse import org.matrix.android.sdk.api.session.sync.model.SyncResponse
import org.matrix.android.sdk.api.util.JsonDict import org.matrix.android.sdk.api.util.JsonDict
@ -161,7 +162,7 @@ internal class ThreadsAwarenessHandler @Inject constructor(
eventEntity: EventEntity? = null): String? { eventEntity: EventEntity? = null): String? {
event ?: return null event ?: return null
roomId ?: return null roomId ?: return null
if (lightweightSettingsStorage.areThreadMessagesEnabled()) return null if (lightweightSettingsStorage.areThreadMessagesEnabled() && !isReplyEvent(event)) return null
handleRootThreadEventsIfNeeded(realm, roomId, eventEntity, event) handleRootThreadEventsIfNeeded(realm, roomId, eventEntity, event)
if (!isThreadEvent(event)) return null if (!isThreadEvent(event)) return null
val eventPayload = if (!event.isEncrypted()) { val eventPayload = if (!event.isEncrypted()) {
@ -170,8 +171,9 @@ internal class ThreadsAwarenessHandler @Inject constructor(
event.mxDecryptionResult?.payload?.toMutableMap() ?: return null event.mxDecryptionResult?.payload?.toMutableMap() ?: return null
} }
val eventBody = event.getDecryptedTextSummary() ?: return null val eventBody = event.getDecryptedTextSummary() ?: return null
val threadRelation = getRootThreadRelationContent(event)
val eventIdToInject = getPreviousEventOrRoot(event) ?: run { 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 eventToInject = getEventFromDB(realm, eventIdToInject)
val eventToInjectBody = eventToInject?.getDecryptedTextSummary() val eventToInjectBody = eventToInject?.getDecryptedTextSummary()
@ -183,17 +185,19 @@ internal class ThreadsAwarenessHandler @Inject constructor(
roomId = roomId, roomId = roomId,
eventBody = eventBody, eventBody = eventBody,
eventToInject = eventToInject, eventToInject = eventToInject,
eventToInjectBody = eventToInjectBody) ?: return null eventToInjectBody = eventToInjectBody,
threadRelation = threadRelation) ?: return null
// update the event // update the event
contentForNonEncrypted = updateEventEntity(event, eventEntity, eventPayload, messageTextContent) contentForNonEncrypted = updateEventEntity(event, eventEntity, eventPayload, messageTextContent)
} else { } 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 // Now lets try to find relations for improved results, while some events may come with reverse order
eventEntity?.let { eventEntity?.let {
// When eventEntity is not null means that we are not from within roomSyncHandler // 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 return contentForNonEncrypted
} }
@ -205,11 +209,16 @@ internal class ThreadsAwarenessHandler @Inject constructor(
* @param event the current event received * @param event the current event received
* @return The content to inject in the roomSyncHandler live events * @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)) { if (!isThreadEvent(event) && cacheEventRootId.contains(eventEntity?.eventId)) {
eventEntity?.let { eventEntity?.let {
val eventBody = event.getDecryptedTextSummary() ?: return null val eventBody = event.getDecryptedTextSummary() ?: return null
return handleEventsThatRelatesTo(realm, roomId, event, eventBody, true) return handleEventsThatRelatesTo(realm, roomId, event, eventBody, true, null)
} }
} }
return 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 * @param isFromCache determines whether or not we already know this is root thread event
* @return The content to inject in the roomSyncHandler live events * @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 event.eventId ?: return null
val rootThreadEventId = if (isFromCache) event.eventId else event.getRootThreadEventId() ?: return null val rootThreadEventId = if (isFromCache) event.eventId else event.getRootThreadEventId() ?: return null
eventThatRelatesTo(realm, event.eventId, rootThreadEventId)?.forEach { eventEntityFound -> eventThatRelatesTo(realm, event.eventId, rootThreadEventId)?.forEach { eventEntityFound ->
@ -236,7 +252,8 @@ internal class ThreadsAwarenessHandler @Inject constructor(
roomId = roomId, roomId = roomId,
eventBody = newEventBody, eventBody = newEventBody,
eventToInject = event, eventToInject = event,
eventToInjectBody = eventBody) ?: return null eventToInjectBody = eventBody,
threadRelation = threadRelation) ?: return null
return updateEventEntity(newEventFound, eventEntityFound, newEventPayload, messageTextContent) return updateEventEntity(newEventFound, eventEntityFound, newEventPayload, messageTextContent)
} }
@ -280,7 +297,9 @@ internal class ThreadsAwarenessHandler @Inject constructor(
private fun injectEvent(roomId: String, private fun injectEvent(roomId: String,
eventBody: String, eventBody: String,
eventToInject: Event, eventToInject: Event,
eventToInjectBody: String): Content? { eventToInjectBody: String,
threadRelation: RelationDefaultContent?
): Content? {
val eventToInjectId = eventToInject.eventId ?: return null val eventToInjectId = eventToInject.eventId ?: return null
val eventIdToInjectSenderId = eventToInject.senderId.orEmpty() val eventIdToInjectSenderId = eventToInject.senderId.orEmpty()
val permalink = permalinkFactory.createPermalink(roomId, eventToInjectId, false) val permalink = permalinkFactory.createPermalink(roomId, eventToInjectId, false)
@ -293,6 +312,7 @@ internal class ThreadsAwarenessHandler @Inject constructor(
eventBody) eventBody)
return MessageTextContent( return MessageTextContent(
relatesTo = threadRelation,
msgType = MessageType.MSGTYPE_TEXT, msgType = MessageType.MSGTYPE_TEXT,
format = MessageFormat.FORMAT_MATRIX_HTML, format = MessageFormat.FORMAT_MATRIX_HTML,
body = eventBody, body = eventBody,
@ -306,12 +326,14 @@ internal class ThreadsAwarenessHandler @Inject constructor(
private fun injectFallbackIndicator(event: Event, private fun injectFallbackIndicator(event: Event,
eventBody: String, eventBody: String,
eventEntity: EventEntity?, eventEntity: EventEntity?,
eventPayload: MutableMap<String, Any>): String? { eventPayload: MutableMap<String, Any>,
threadRelation: RelationDefaultContent?): String? {
val replyFormatted = LocalEchoEventFactory.QUOTE_PATTERN.format( val replyFormatted = LocalEchoEventFactory.QUOTE_PATTERN.format(
"In reply to a thread", "In reply to a thread",
eventBody) eventBody)
val messageTextContent = MessageTextContent( val messageTextContent = MessageTextContent(
relatesTo = threadRelation,
msgType = MessageType.MSGTYPE_TEXT, msgType = MessageType.MSGTYPE_TEXT,
format = MessageFormat.FORMAT_MATRIX_HTML, format = MessageFormat.FORMAT_MATRIX_HTML,
body = eventBody, body = eventBody,
@ -332,7 +354,7 @@ internal class ThreadsAwarenessHandler @Inject constructor(
.findAll() .findAll()
cacheEventRootId.add(rootThreadEventId) cacheEventRootId.add(rootThreadEventId)
return threadList.filter { 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 * @param event
*/ */
private fun isThreadEvent(event: Event): Boolean = private fun isThreadEvent(event: Event): Boolean =
event.content.toModel<MessageRelationContent>()?.relatesTo?.type == RelationType.IO_THREAD event.content.toModel<MessageRelationContent>()?.relatesTo?.type == RelationType.THREAD
/** /**
* Returns the root thread eventId or null otherwise * Returns the root thread eventId or null otherwise
@ -359,9 +381,22 @@ internal class ThreadsAwarenessHandler @Inject constructor(
private fun getRootThreadEventId(event: Event): String? = private fun getRootThreadEventId(event: Event): String? =
event.content.toModel<MessageRelationContent>()?.relatesTo?.eventId event.content.toModel<MessageRelationContent>()?.relatesTo?.eventId
private fun getRootThreadRelationContent(event: Event): RelationDefaultContent? =
event.content.toModel<MessageRelationContent>()?.relatesTo
private fun getPreviousEventOrRoot(event: Event): String? = private fun getPreviousEventOrRoot(event: Event): String? =
event.content.toModel<MessageRelationContent>()?.relatesTo?.inReplyTo?.eventId event.content.toModel<MessageRelationContent>()?.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<MessageRelationContent>()?.relatesTo?.isFallingBack == true
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun getValueFromPayload(payload: JsonDict?, key: String): String? { private fun getValueFromPayload(payload: JsonDict?, key: String): String? {
val content = payload?.get("content") as? JsonDict val content = payload?.get("content") as? JsonDict

View file

@ -19,6 +19,7 @@ package org.matrix.android.sdk.api.auth.data
import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBe
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.internal.auth.version.Versions 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 import org.matrix.android.sdk.internal.auth.version.isSupportedBySdk
class VersionsKtTest { 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.0")).isSupportedBySdk() shouldBe true
Versions(supportedVersions = listOf("r0.5.0", "r0.6.1")).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("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
} }
} }

View file

@ -27,6 +27,7 @@ import im.vector.app.espresso.tools.ScreenshotFailureRule
import im.vector.app.features.MainActivity import im.vector.app.features.MainActivity
import im.vector.app.getString import im.vector.app.getString
import im.vector.app.ui.robot.ElementRobot import im.vector.app.ui.robot.ElementRobot
import im.vector.app.ui.robot.settings.labs.LabFeature
import im.vector.app.ui.robot.withDeveloperMode import im.vector.app.ui.robot.withDeveloperMode
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -97,6 +98,8 @@ class UiAllScreensSanityTest {
} }
} }
testThreadScreens()
elementRobot.space { elementRobot.space {
createSpace { createSpace {
crawl() crawl()
@ -148,4 +151,25 @@ class UiAllScreensSanityTest {
// TODO Deactivate account instead of logout? // TODO Deactivate account instead of logout?
elementRobot.signout(expectSignOutWarning = false) 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)
}
} }

View file

@ -17,9 +17,15 @@
package im.vector.app.ui.robot package im.vector.app.ui.robot
import android.view.View import android.view.View
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack 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
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId 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.assertion.BaristaVisibilityAssertions.assertDisplayed
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import com.adevinta.android.barista.interaction.BaristaDialogInteractions.clickDialogNegativeButton 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.features.onboarding.OnboardingActivity
import im.vector.app.initialSyncIdlingResource import im.vector.app.initialSyncIdlingResource
import im.vector.app.ui.robot.settings.SettingsRobot 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.ui.robot.space.SpaceRobot
import im.vector.app.withIdlingResource import im.vector.app.withIdlingResource
import timber.log.Timber import timber.log.Timber
@ -70,11 +77,11 @@ class ElementRobot {
} }
} }
fun settings(block: SettingsRobot.() -> Unit) { fun settings(shouldGoBack: Boolean = true, block: SettingsRobot.() -> Unit) {
openDrawer() openDrawer()
clickOn(R.id.homeDrawerHeaderSettingsView) clickOn(R.id.homeDrawerHeaderSettingsView)
block(SettingsRobot()) block(SettingsRobot())
pressBack() if (shouldGoBack) pressBack()
waitUntilViewVisible(withId(R.id.bottomNavigationView)) waitUntilViewVisible(withId(R.id.bottomNavigationView))
} }
@ -103,6 +110,22 @@ class ElementRobot {
waitUntilViewVisible(withId(R.id.bottomNavigationView)) 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) { fun signout(expectSignOutWarning: Boolean) {
clickOn(R.id.groupToolbarAvatarImageView) clickOn(R.id.groupToolbarAvatarImageView)
clickOn(R.id.homeDrawerHeaderSignoutView) clickOn(R.id.homeDrawerHeaderSignoutView)

View file

@ -70,4 +70,13 @@ class MessageMenuRobot(
clickOn(R.string.edit) clickOn(R.string.edit)
autoClosed = true autoClosed = true
} }
fun replyInThread() {
clickOn(R.string.reply_in_thread)
autoClosed = true
}
fun viewInRoom() {
clickOn(R.string.view_in_room)
autoClosed = true
}
} }

View file

@ -62,6 +62,23 @@ class RoomDetailRobot {
pressBack() 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) { fun crawlMessage(message: String) {
// Test quick reaction // Test quick reaction
val quickReaction = EmojiDataSource.quickEmojis[0] // 👍 val quickReaction = EmojiDataSource.quickEmojis[0] // 👍
@ -110,7 +127,7 @@ class RoomDetailRobot {
onView(withId(R.id.timelineRecyclerView)) onView(withId(R.id.timelineRecyclerView))
.perform( .perform(
RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>( RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
ViewMatchers.hasDescendant(ViewMatchers.withText(message)), ViewMatchers.hasDescendant(withText(message)),
ViewActions.longClick() ViewActions.longClick()
) )
) )
@ -130,4 +147,16 @@ class RoomDetailRobot {
block(RoomSettingsRobot()) block(RoomSettingsRobot())
pressBack() 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()
}
} }

View file

@ -16,6 +16,7 @@
package im.vector.app.ui.robot.settings package im.vector.app.ui.robot.settings
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import im.vector.app.R import im.vector.app.R
import im.vector.app.clickOnAndGoBack import im.vector.app.clickOnAndGoBack
@ -51,8 +52,13 @@ class SettingsRobot {
clickOnAndGoBack(R.string.settings_security_and_privacy) { block(SettingsSecurityRobot()) } clickOnAndGoBack(R.string.settings_security_and_privacy) { block(SettingsSecurityRobot()) }
} }
fun labs(block: () -> Unit = {}) { fun labs(shouldGoBack: Boolean = true, block: () -> Unit = {}) {
clickOnAndGoBack(R.string.room_settings_labs_pref_title) { block() } 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) { fun advancedSettings(block: SettingsAdvancedRobot.() -> Unit) {

View file

@ -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
}

View file

@ -158,6 +158,9 @@ class TimelineViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<TimelineViewModel, RoomDetailViewState> by hiltMavericksViewModelFactory() { companion object : MavericksViewModelFactory<TimelineViewModel, RoomDetailViewState> by hiltMavericksViewModelFactory() {
const val PAGINATION_COUNT = 50 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 { init {
@ -503,7 +506,10 @@ class TimelineViewModel @AssistedInject constructor(
private fun handleSendSticker(action: RoomDetailAction.SendSticker) { private fun handleSendSticker(action: RoomDetailAction.SendSticker) {
val content = initialState.rootThreadEventId?.let { 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 } ?: action.stickerContent
room.sendEvent(EventType.STICKER, content.toContent()) 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<TimelineEvent>) {
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<TimelineEvent>) { override fun onTimelineUpdated(snapshot: List<TimelineEvent>) {
viewModelScope.launch { viewModelScope.launch {
// tryEmit doesn't work with SharedFlow without cache // tryEmit doesn't work with SharedFlow without cache
timelineEvents.emit(snapshot) timelineEvents.emit(snapshot)
navigateToThreadEventIfNeeded(snapshot)
} }
} }

View file

@ -465,7 +465,8 @@ class MessageComposerViewModel @AssistedInject constructor(
// is original event a reply? // is original event a reply?
val relationContent = state.sendMode.timelineEvent.getRelationContent() val relationContent = state.sendMode.timelineEvent.getRelationContent()
val inReplyTo = if (state.rootThreadEventId != null) { val inReplyTo = if (state.rootThreadEventId != null) {
if (relationContent?.inReplyTo?.shouldRenderInThread() == true) { // Thread event
if (relationContent?.shouldRenderInThread() == true) {
// Reply within a thread event // Reply within a thread event
relationContent.inReplyTo?.eventId relationContent.inReplyTo?.eventId
} else { } else {
@ -509,6 +510,7 @@ class MessageComposerViewModel @AssistedInject constructor(
is SendMode.Reply -> { is SendMode.Reply -> {
val timelineEvent = state.sendMode.timelineEvent val timelineEvent = state.sendMode.timelineEvent
val showInThread = state.sendMode.timelineEvent.root.isThread() && state.rootThreadEventId == null 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 val rootThreadEventId = if (showInThread) timelineEvent.root.getRootThreadEventId() else null
state.rootThreadEventId?.let { state.rootThreadEventId?.let {
room.replyInThread( room.replyInThread(

View file

@ -32,6 +32,7 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.core.resources.UserPreferencesProvider
import im.vector.app.core.ui.list.GenericHeaderItem_ import im.vector.app.core.ui.list.GenericHeaderItem_
import im.vector.app.features.home.AvatarRenderer 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 im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Content
@ -45,6 +46,7 @@ class SearchResultController @Inject constructor(
private val avatarRenderer: AvatarRenderer, private val avatarRenderer: AvatarRenderer,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val dateFormatter: VectorDateFormatter, private val dateFormatter: VectorDateFormatter,
private val displayableEventFormatter: DisplayableEventFormatter,
private val userPreferencesProvider: UserPreferencesProvider private val userPreferencesProvider: UserPreferencesProvider
) : TypedEpoxyController<SearchViewState>() { ) : TypedEpoxyController<SearchViewState>() {
@ -125,6 +127,7 @@ class SearchResultController @Inject constructor(
.sender(eventAndSender.sender .sender(eventAndSender.sender
?: eventAndSender.event.senderId?.let { session.getRoomMember(it, data.roomId) }?.toMatrixItem()) ?: eventAndSender.event.senderId?.let { session.getRoomMember(it, data.roomId) }?.toMatrixItem())
.threadDetails(event.threadDetails) .threadDetails(event.threadDetails)
.threadSummaryFormatted(displayableEventFormatter.formatThreadSummary(event.threadDetails?.threadSummaryLatestEvent).toString())
.areThreadMessagesEnabled(userPreferencesProvider.areThreadMessagesEnabled()) .areThreadMessagesEnabled(userPreferencesProvider.areThreadMessagesEnabled())
.listener { listener?.onItemClicked(eventAndSender.event) } .listener { listener?.onItemClicked(eventAndSender.event) }
.let { result.add(it) } .let { result.add(it) }

View file

@ -42,6 +42,7 @@ abstract class SearchResultItem : VectorEpoxyModel<SearchResultItem.Holder>() {
@EpoxyAttribute lateinit var spannable: EpoxyCharSequence @EpoxyAttribute lateinit var spannable: EpoxyCharSequence
@EpoxyAttribute var sender: MatrixItem? = null @EpoxyAttribute var sender: MatrixItem? = null
@EpoxyAttribute var threadDetails: ThreadDetails? = null @EpoxyAttribute var threadDetails: ThreadDetails? = null
@EpoxyAttribute var threadSummaryFormatted: String? = null
@EpoxyAttribute var areThreadMessagesEnabled: Boolean = false @EpoxyAttribute var areThreadMessagesEnabled: Boolean = false
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var listener: ClickListener? = null @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var listener: ClickListener? = null
@ -60,8 +61,7 @@ abstract class SearchResultItem : VectorEpoxyModel<SearchResultItem.Holder>() {
if (it.isRootThread) { if (it.isRootThread) {
showThreadSummary(holder) showThreadSummary(holder)
holder.threadSummaryCounterTextView.text = it.numberOfThreads.toString() 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 userId = it.threadSummarySenderInfo?.userId ?: return@let
val displayName = it.threadSummarySenderInfo?.displayName val displayName = it.threadSummarySenderInfo?.displayName
val avatarUrl = it.threadSummarySenderInfo?.avatarUrl val avatarUrl = it.threadSummarySenderInfo?.avatarUrl

View file

@ -24,9 +24,11 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.html.EventHtmlRenderer
import me.gujun.android.span.span import me.gujun.android.span.span
import org.commonmark.node.Document 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.EventType
import org.matrix.android.sdk.api.session.events.model.toModel 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.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.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent 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.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<ReactionContent>()?.relatesTo?.let {
emojiSpanify.spanify(stringProvider.getString(R.string.sent_a_reaction, it.key))
} ?: span { }
}
in EventType.POLL_START -> {
event.getClearContent().toModel<MessagePollContent>(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 { private fun simpleFormat(senderName: String, body: CharSequence, appendAuthor: Boolean): CharSequence {
return if (appendAuthor) { return if (appendAuthor) {
span { span {

View file

@ -22,6 +22,7 @@ import im.vector.app.core.resources.UserPreferencesProvider
import im.vector.app.features.home.AvatarRenderer 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.MessageColorProvider
import im.vector.app.features.home.room.detail.timeline.TimelineEventController 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.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryEvents 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 messageColorProvider: MessageColorProvider,
private val avatarSizeProvider: AvatarSizeProvider, private val avatarSizeProvider: AvatarSizeProvider,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val displayableEventFormatter: DisplayableEventFormatter,
private val preferencesProvider: UserPreferencesProvider, private val preferencesProvider: UserPreferencesProvider,
private val emojiCompatFontProvider: EmojiCompatFontProvider) { private val emojiCompatFontProvider: EmojiCompatFontProvider) {
@ -61,6 +63,7 @@ class MessageItemAttributesFactory @Inject constructor(
readReceiptsCallback = callback, readReceiptsCallback = callback,
emojiTypeFace = emojiCompatFontProvider.typeface, emojiTypeFace = emojiCompatFontProvider.typeface,
decryptionErrorMessage = stringProvider.getString(R.string.encrypted_message), decryptionErrorMessage = stringProvider.getString(R.string.encrypted_message),
threadSummaryFormatted = displayableEventFormatter.formatThreadSummary(threadDetails?.threadSummaryLatestEvent).toString(),
threadDetails = threadDetails, threadDetails = threadDetails,
reactionsSummaryEvents = reactionsSummaryEvents, reactionsSummaryEvents = reactionsSummaryEvents,
areThreadMessagesEnabled = preferencesProvider.areThreadMessagesEnabled() areThreadMessagesEnabled = preferencesProvider.areThreadMessagesEnabled()

View file

@ -116,7 +116,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
attributes.threadDetails?.let { threadDetails -> attributes.threadDetails?.let { threadDetails ->
holder.threadSummaryConstraintLayout.isVisible = threadDetails.isRootThread holder.threadSummaryConstraintLayout.isVisible = threadDetails.isRootThread
holder.threadSummaryCounterTextView.text = threadDetails.numberOfThreads.toString() 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 userId = threadDetails.threadSummarySenderInfo?.userId ?: return@let
val displayName = threadDetails.threadSummarySenderInfo?.displayName val displayName = threadDetails.threadSummarySenderInfo?.displayName
@ -184,6 +184,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null,
val emojiTypeFace: Typeface? = null, val emojiTypeFace: Typeface? = null,
val decryptionErrorMessage: String? = null, val decryptionErrorMessage: String? = null,
val threadSummaryFormatted: String? = null,
val threadDetails: ThreadDetails? = null, val threadDetails: ThreadDetails? = null,
val areThreadMessagesEnabled: Boolean = false, val areThreadMessagesEnabled: Boolean = false,
override val reactionsSummaryEvents: ReactionsSummaryEvents? = null, override val reactionsSummaryEvents: ReactionsSummaryEvents? = null,

View file

@ -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.ThreadListArgs
import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs
import im.vector.app.features.home.room.threads.list.views.ThreadListFragment 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 import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -92,14 +91,7 @@ class ThreadsActivity : VectorBaseActivity<ActivityThreadsBinding>() {
* This function is used to navigate to the selected thread timeline. * This function is used to navigate to the selected thread timeline.
* One usage of that is from the Threads Activity * One usage of that is from the Threads Activity
*/ */
fun navigateToThreadTimeline( fun navigateToThreadTimeline(threadTimelineArgs: ThreadTimelineArgs) {
timelineEvent: TimelineEvent) {
val roomThreadDetailArgs = ThreadTimelineArgs(
roomId = timelineEvent.roomId,
displayName = timelineEvent.senderInfo.displayName,
avatarUrl = timelineEvent.senderInfo.avatarUrl,
roomEncryptionTrustLevel = null,
rootThreadEventId = timelineEvent.eventId)
val commonOption: (FragmentTransaction) -> Unit = { val commonOption: (FragmentTransaction) -> Unit = {
it.setCustomAnimations( it.setCustomAnimations(
R.anim.animation_slide_in_right, R.anim.animation_slide_in_right,
@ -111,8 +103,8 @@ class ThreadsActivity : VectorBaseActivity<ActivityThreadsBinding>() {
container = views.threadsActivityFragmentContainer, container = views.threadsActivityFragmentContainer,
fragmentClass = TimelineFragment::class.java, fragmentClass = TimelineFragment::class.java,
params = TimelineArgs( params = TimelineArgs(
roomId = timelineEvent.roomId, roomId = threadTimelineArgs.roomId,
threadTimelineArgs = roomThreadDetailArgs threadTimelineArgs = threadTimelineArgs
), ),
option = commonOption option = commonOption
) )

View file

@ -17,21 +17,26 @@
package im.vector.app.features.home.room.threads.list.viewmodel package im.vector.app.features.home.room.threads.list.viewmodel
import com.airbnb.epoxy.EpoxyController import com.airbnb.epoxy.EpoxyController
import im.vector.app.R
import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.AvatarRenderer 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 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.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.threads.ThreadNotificationState import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.api.util.toMatrixItemOrNull
import javax.inject.Inject import javax.inject.Inject
class ThreadListController @Inject constructor( class ThreadListController @Inject constructor(
private val avatarRenderer: AvatarRenderer, private val avatarRenderer: AvatarRenderer,
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val dateFormatter: VectorDateFormatter private val dateFormatter: VectorDateFormatter,
private val displayableEventFormatter: DisplayableEventFormatter,
private val session: Session
) : EpoxyController() { ) : EpoxyController() {
var listener: Listener? = null var listener: Listener? = null
@ -43,10 +48,68 @@ class ThreadListController @Inject constructor(
requestModelBuild() 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 safeViewState = viewState ?: return
val host = this 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() safeViewState.rootThreadEventList.invoke()
?.filter { ?.filter {
if (safeViewState.shouldFilterThreads) { if (safeViewState.shouldFilterThreads) {
@ -59,28 +122,39 @@ class ThreadListController @Inject constructor(
} }
?.forEach { timelineEvent -> ?.forEach { timelineEvent ->
val date = dateFormatter.format(timelineEvent.root.threadDetails?.lastMessageTimestamp, DateFormatKind.ROOM_LIST) 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 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 { threadListItem {
id(timelineEvent.eventId) id(timelineEvent.eventId)
avatarRenderer(host.avatarRenderer) avatarRenderer(host.avatarRenderer)
matrixItem(timelineEvent.senderInfo.toMatrixItem()) matrixItem(timelineEvent.senderInfo.toMatrixItem())
title(timelineEvent.senderInfo.displayName) title(timelineEvent.senderInfo.displayName.orEmpty())
date(date) date(date)
rootMessageDeleted(timelineEvent.root.isRedacted()) rootMessageDeleted(timelineEvent.root.isRedacted())
threadNotificationState(timelineEvent.root.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE) threadNotificationState(timelineEvent.root.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE)
rootMessage(lastRootThreadEdition ?: timelineEvent.root.getDecryptedTextSummary() ?: decryptionErrorMessage) rootMessage(rootMessageFormatted)
lastMessage(timelineEvent.root.threadDetails?.threadSummaryLatestTextMessage ?: decryptionErrorMessage) lastMessage(lastMessageFormatted)
lastMessageCounter(timelineEvent.root.threadDetails?.numberOfThreads.toString()) lastMessageCounter(timelineEvent.root.threadDetails?.numberOfThreads.toString())
lastMessageMatrixItem(timelineEvent.root.threadDetails?.threadSummarySenderInfo?.toMatrixItem()) lastMessageMatrixItem(timelineEvent.root.threadDetails?.threadSummarySenderInfo?.toMatrixItem())
itemClickListener { itemClickListener {
host.listener?.onThreadClicked(timelineEvent) host.listener?.onThreadListClicked(timelineEvent)
} }
} }
} }
} }
interface Listener { interface Listener {
fun onThreadClicked(timelineEvent: TimelineEvent) fun onThreadSummaryClicked(threadSummary: ThreadSummary)
fun onThreadListClicked(timelineEvent: TimelineEvent)
} }
} }

View file

@ -28,6 +28,7 @@ import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.home.room.threads.list.views.ThreadListFragment import im.vector.app.features.home.room.threads.list.views.ThreadListFragment
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent
import org.matrix.android.sdk.flow.flow import org.matrix.android.sdk.flow.flow
@ -53,11 +54,43 @@ class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState
} }
init { init {
observeThreadsList() fetchAndObserveThreads()
} }
override fun handle(action: EmptyAction) {} 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() { private fun observeThreadsList() {
room?.flow() room?.flow()
?.liveThreadList() ?.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) { fun applyFiltering(shouldFilterThreads: Boolean) {
setState { setState {
copy(shouldFilterThreads = shouldFilterThreads) copy(shouldFilterThreads = shouldFilterThreads)

View file

@ -20,13 +20,14 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.home.room.threads.arguments.ThreadListArgs 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 import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent
data class ThreadListViewState( data class ThreadListViewState(
val threadSummaryList: Async<List<ThreadSummary>> = Uninitialized,
val rootThreadEventList: Async<List<ThreadTimelineEvent>> = Uninitialized, val rootThreadEventList: Async<List<ThreadTimelineEvent>> = Uninitialized,
val shouldFilterThreads: Boolean = false, val shouldFilterThreads: Boolean = false,
val roomId: String val roomId: String
) : MavericksState { ) : MavericksState {
constructor(args: ThreadListArgs) : this(roomId = args.roomId) constructor(args: ThreadListArgs) : this(roomId = args.roomId)
} }

View file

@ -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.detail.timeline.animation.TimelineItemAnimator
import im.vector.app.features.home.room.threads.ThreadsActivity 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.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.ThreadListController
import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewModel import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewModel
import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewState 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.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
import javax.inject.Inject import javax.inject.Inject
@ -111,12 +113,30 @@ class ThreadListFragment @Inject constructor(
views.includeThreadListToolbar.roomToolbarThreadSubtitleTextView.text = threadListArgs.displayName views.includeThreadListToolbar.roomToolbarThreadSubtitleTextView.text = threadListArgs.displayName
} }
override fun onThreadClicked(timelineEvent: TimelineEvent) { override fun onThreadSummaryClicked(threadSummary: ThreadSummary) {
(activity as? ThreadsActivity)?.navigateToThreadTimeline(timelineEvent) 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) { private fun renderEmptyStateIfNeeded(state: ThreadListViewState) {
val show = state.rootThreadEventList.invoke().isNullOrEmpty() when (threadListViewModel.canHomeserverUseThreading()) {
views.threadListEmptyConstraintLayout.isVisible = show true -> views.threadListEmptyConstraintLayout.isVisible = state.threadSummaryList.invoke().isNullOrEmpty()
false -> views.threadListEmptyConstraintLayout.isVisible = state.rootThreadEventList.invoke().isNullOrEmpty()
}
} }
} }