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.notification.RoomNotificationState
import org.matrix.android.sdk.api.session.room.send.UserDraft
import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Optional
import org.matrix.android.sdk.api.util.toOptional
@ -101,13 +102,18 @@ class FlowRoom(private val room: Room) {
return room.getLiveRoomNotificationState().asFlow()
}
fun liveThreadSummaries(): Flow<List<ThreadSummary>> {
return room.getAllThreadSummariesLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
room.getAllThreadSummaries()
}
}
fun liveThreadList(): Flow<List<ThreadRootEvent>> {
return room.getAllThreadsLive().asFlow()
.startWith(room.coroutineDispatchers.io) {
room.getAllThreads()
}
}
fun liveLocalUnreadThreadList(): Flow<List<ThreadRootEvent>> {
return room.getMarkedThreadNotificationsLive().asFlow()
.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 {
realm.copyToRealm(it)
}
chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap())
chunk.addTimelineEvent(
roomId = ROOM_ID,
eventEntity = fakeEvent,
direction = PaginationDirection.FORWARDS,
roomMemberContentsByUser = emptyMap())
chunk.timelineEvents.size shouldBeEqualTo 1
}
}
@ -74,8 +78,16 @@ internal class ChunkEntityTest : InstrumentedTest {
val fakeEvent = createFakeMessageEvent().toEntity(ROOM_ID, SendState.SYNCED, System.currentTimeMillis()).let {
realm.copyToRealm(it)
}
chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap())
chunk.addTimelineEvent(ROOM_ID, fakeEvent, PaginationDirection.FORWARDS, emptyMap())
chunk.addTimelineEvent(
roomId = ROOM_ID,
eventEntity = fakeEvent,
direction = PaginationDirection.FORWARDS,
roomMemberContentsByUser = emptyMap())
chunk.addTimelineEvent(
roomId = ROOM_ID,
eventEntity = fakeEvent,
direction = PaginationDirection.FORWARDS,
roomMemberContentsByUser = emptyMap())
chunk.timelineEvents.size shouldBeEqualTo 1
}
}
@ -144,7 +156,11 @@ internal class ChunkEntityTest : InstrumentedTest {
val fakeEvent = event.toEntity(roomId, SendState.SYNCED, System.currentTimeMillis()).let {
realm.copyToRealm(it)
}
addTimelineEvent(roomId, fakeEvent, direction, emptyMap())
addTimelineEvent(
roomId = roomId,
eventEntity = fakeEvent,
direction = direction,
roomMemberContentsByUser = emptyMap())
}
}

View file

@ -49,5 +49,6 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class AggregatedRelations(
@Json(name = "m.annotation") val annotations: AggregatedAnnotation? = null,
@Json(name = "m.reference") val references: DefaultUnsignedRelationInfo? = null
@Json(name = "m.reference") val references: DefaultUnsignedRelationInfo? = null,
@Json(name = RelationType.THREAD) val latestThread: LatestThreadUnsignedRelation? = null
)

View file

@ -201,7 +201,11 @@ data class Event(
*/
fun getDecryptedTextSummary(): String? {
if (isRedacted()) return "Message Deleted"
val text = getDecryptedValue() ?: return null
val text = getDecryptedValue() ?: run {
if (isPoll()) { return getPollQuestion() ?: "created a poll." }
return null
}
return when {
isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text)
isFileMessage() -> "sent a file."
@ -385,12 +389,12 @@ fun Event.isReply(): Boolean {
}
fun Event.isReplyRenderedInThread(): Boolean {
return isReply() && getRelationContent()?.inReplyTo?.shouldRenderInThread() == true
return isReply() && getRelationContent()?.shouldRenderInThread() == true
}
fun Event.isThread(): Boolean = getRelationContentForType(RelationType.IO_THREAD)?.eventId != null
fun Event.isThread(): Boolean = getRelationContentForType(RelationType.THREAD)?.eventId != null
fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.IO_THREAD)?.eventId
fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.THREAD)?.eventId
fun Event.isEdition(): Boolean {
return getRelationContentForType(RelationType.REPLACE)?.eventId != null

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.*/
const val THREAD = "m.thread"
const val IO_THREAD = "io.element.thread"
/** Lets you define an event which adds a response to an existing event.*/
const val RESPONSE = "org.matrix.response"

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.
* 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 {

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

View file

@ -26,5 +26,6 @@ data class ReactionInfo(
@Json(name = "key") val key: String,
// always null for reaction
@Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null,
@Json(name = "option") override val option: Int? = null
@Json(name = "option") override val option: Int? = null,
@Json(name = "is_falling_back") override val isFallingBack: Boolean? = null
) : RelationContent

View file

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

View file

@ -23,5 +23,8 @@ data class RelationDefaultContent(
@Json(name = "rel_type") override val type: String?,
@Json(name = "event_id") override val eventId: String?,
@Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null,
@Json(name = "option") override val option: Int? = null
@Json(name = "option") override val option: Int? = null,
@Json(name = "is_falling_back") override val isFallingBack: Boolean? = null
) : RelationContent
fun RelationDefaultContent.shouldRenderInThread(): Boolean = isFallingBack == false

View file

@ -163,13 +163,4 @@ interface RelationService {
autoMarkdown: Boolean = false,
formattedText: String? = null,
eventReplied: TimelineEvent? = null): Cancelable?
/**
* Get all the thread replies for the specified rootThreadEventId
* The return list will contain the original root thread event and all the thread replies to that event
* Note: We will use a large limit value in order to avoid using pagination until it would be 100% ready
* from the backend
* @param rootThreadEventId the root thread eventId
*/
suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean
}

View file

@ -21,8 +21,5 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class ReplyToContent(
@Json(name = "event_id") val eventId: String? = null,
@Json(name = "render_in") val renderIn: List<String>? = null
@Json(name = "event_id") val eventId: 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
import androidx.lifecycle.LiveData
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
/**
* This interface defines methods to interact with threads related features.
* It's implemented at the room level within the main timeline.
* This interface defines methods to interact with thread related features.
* It's the dynamic threads implementation and the homeserver must return
* a capability entry for threads. If the server do not support m.thread
* then [ThreadsLocalService] should be used instead
*/
interface ThreadsService {
/**
* Returns a [LiveData] list of all the thread root TimelineEvents that exists at the room level
* Returns a [LiveData] list of all the [ThreadSummary] that exists at the room level
*/
fun getAllThreadsLive(): LiveData<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
*/
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
* Enhance the provided ThreadSummary[List] by adding the latest
* message edition for that thread
* @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.
* note: read receipts within threads are not yet supported with the API
* @param rootThreadEventId the root eventId of the current thread
* Fetch all thread replies for the specified thread using the /relations api
* @param rootThreadEventId the root thread eventId
* @param from defines the token that will fetch from that position
* @param limit defines the number of max results the api will respond with
*/
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.
*/
val displayIndex: Int,
var ownedByThreadChunk: Boolean = false,
val senderInfo: SenderInfo,
val annotations: EventAnnotationsSummary? = null,
val readReceipts: List<ReadReceipt> = emptyList()

View file

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

View file

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

View file

@ -38,7 +38,7 @@ internal data class HomeServerVersion(
}
companion object {
internal val pattern = Regex("""r(\d+)\.(\d+)\.(\d+)""")
internal val pattern = Regex("""[r|v](\d+)\.(\d+)\.(\d+)""")
internal fun parse(value: String): HomeServerVersion? {
val result = pattern.matchEntire(value) ?: return null
@ -56,5 +56,6 @@ internal data class HomeServerVersion(
val r0_4_0 = HomeServerVersion(major = 0, minor = 4, patch = 0)
val r0_5_0 = HomeServerVersion(major = 0, minor = 5, patch = 0)
val r0_6_0 = HomeServerVersion(major = 0, minor = 6, patch = 0)
val v1_3_0 = HomeServerVersion(major = 1, minor = 3, patch = 0)
}
}

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_ID_ACCESS_TOKEN = "m.id_access_token"
private const val FEATURE_SEPARATE_ADD_AND_BIND = "m.separate_add_and_bind"
private const val FEATURE_THREADS_MSC3440 = "org.matrix.msc3440"
private const val FEATURE_THREADS_MSC3440_STABLE = "org.matrix.msc3440.stable"
/**
* Return true if the SDK supports this homeserver version
@ -68,6 +70,14 @@ internal fun Versions.isLoginAndRegistrationSupportedBySdk(): Boolean {
doesServerSeparatesAddAndBind()
}
/**
* Indicate if the homeserver support MSC3440 for threads
*/
internal fun Versions.doesServerSupportThreads(): Boolean {
return getMaxVersion() >= HomeServerVersion.v1_3_0 ||
unstableFeatures?.get(FEATURE_THREADS_MSC3440_STABLE) ?: false
}
/**
* Return true if the server support the lazy loading of room members
*

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

View file

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

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

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.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
@ -93,11 +93,12 @@ internal fun EventEntity.markEventAsRoot(
* @param rootThreadEventId The root eventId that will find the number of threads
* @return A ThreadSummary containing the counted threads and the latest event message
*/
internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: String, chunkEntity: ChunkEntity?): ThreadSummary {
internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: String, chunkEntity: ChunkEntity?): Summary {
// Number of messages
val messages = TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.distinct(TimelineEventEntityFields.ROOT.EVENT_ID)
.count()
.toInt()
@ -123,7 +124,7 @@ internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId:
result ?: return null
return ThreadSummary(messages, result)
return Summary(messages, result)
}
/**
@ -156,6 +157,7 @@ internal fun TimelineEventEntity.Companion.findAllThreadsForRoomId(realm: Realm,
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true)
.equalTo(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, false)
.sort("${TimelineEventEntityFields.ROOT.THREAD_SUMMARY_LATEST_MESSAGE}.${TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS}", Sort.DESCENDING)
/**

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,
threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedTextSummary(),
threadSummaryLatestEvent = eventEntity.threadSummaryLatestMessage?.root?.asDomain(),
lastMessageTimestamp = eventEntity.threadSummaryLatestMessage?.root?.originServerTs
)

View file

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

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,
avatarUrl = timelineEventEntity.senderAvatar
),
ownedByThreadChunk = timelineEventEntity.ownedByThreadChunk,
readReceipts = readReceipts
?.distinctBy {
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(),
// Only one chunk will have isLastForward == true
@Index var isLastForward: Boolean = false,
@Index var isLastBackward: Boolean = false
@Index var isLastBackward: Boolean = false,
// Threads
@Index var rootThreadEventId: String? = null,
@Index var isLastForwardThread: Boolean = false,
) : RealmObject() {
fun identifier() = "${prevToken}_$nextToken"
@ -47,14 +50,32 @@ internal open class ChunkEntity(@Index var prevToken: String? = null,
companion object
}
internal fun ChunkEntity.deleteOnCascade(deleteStateEvents: Boolean, canDeleteRoot: Boolean) {
internal fun ChunkEntity.deleteOnCascade(
deleteStateEvents: Boolean,
canDeleteRoot: Boolean) {
assertIsManaged()
if (deleteStateEvents) {
stateEvents.deleteAllFromRealm()
}
timelineEvents.clearWith {
val deleteRoot = canDeleteRoot && (it.root?.stateKey == null || deleteStateEvents)
if (deleteRoot) {
room?.firstOrNull()?.removeThreadSummaryIfNeeded(it.eventId)
}
it.deleteOnCascade(deleteRoot)
}
deleteFromRealm()
}
/**
* Delete the chunk along with the thread events that were temporarily created
*/
internal fun ChunkEntity.deleteAndClearThreadEvents() {
assertIsManaged()
timelineEvents
.filter { it.ownedByThreadChunk }
.forEach {
it.deleteOnCascade(false)
}
deleteFromRealm()
}

View file

@ -34,14 +34,14 @@ internal open class EventEntity(@Index var eventId: String = "",
@Index var stateKey: String? = null,
var originServerTs: Long? = null,
@Index var sender: String? = null,
// Can contain a serialized MatrixError
// Can contain a serialized MatrixError
var sendStateDetails: String? = null,
var age: Long? = 0,
var unsignedData: String? = null,
var redacts: String? = null,
var decryptionResultJson: String? = null,
var ageLocalTs: Long? = null,
// Thread related, no need to create a new Entity for performance
// Thread related, no need to create a new Entity for performance
@Index var isRootThread: Boolean = false,
@Index var rootThreadEventId: String? = null,
var numberOfThreads: Int = 0,

View file

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

View file

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

View file

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

View file

@ -32,6 +32,9 @@ internal open class TimelineEventEntity(var localId: Long = 0,
var isUniqueDisplayName: Boolean = false,
var senderAvatar: String? = null,
var senderMembershipEventId: String? = null,
// ownedByThreadChunk indicates that the current TimelineEventEntity belongs
// to a thread chunk and is a temporarily event.
var ownedByThreadChunk: Boolean = false,
var readReceipts: ReadReceiptsSummaryEntity? = null
) : RealmObject() {

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)
.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> {
return realm.where<ChunkEntity>()
.`in`(ChunkEntityFields.TIMELINE_EVENTS.EVENT_ID, eventIds.toTypedArray())
.isNull(ChunkEntityFields.ROOT_THREAD_EVENT_ID)
.findAll()
}

View file

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

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
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType
import timber.log.Timber
internal object FilterFactory {
fun createThreadsFilter(numberOfEvents: Int, userId: String?): RoomEventFilter {
Timber.i("$userId")
return RoomEventFilter(
limit = numberOfEvents,
// senders = listOf(userId),
// relationSenders = userId?.let { listOf(it) },
relationTypes = listOf(RelationType.THREAD)
)
}
fun createUploadsFilter(numberOfEvents: Int): RoomEventFilter {
return RoomEventFilter(
limit = numberOfEvents,
@ -58,8 +70,8 @@ internal object FilterFactory {
private fun createElementTimelineFilter(): RoomEventFilter? {
return null // RoomEventFilter().apply {
// TODO Enable this for optimization
// types = listOfSupportedEventTypes.toMutableList()
// TODO Enable this for optimization
// types = listOfSupportedEventTypes.toMutableList()
// }
}

View file

@ -52,12 +52,13 @@ data class RoomEventFilter(
* A list of relation types which must be exist pointing to the event being filtered.
* If this list is absent then no filtering is done on relation types.
*/
@Json(name = "relation_types") val relationTypes: List<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.
* 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.
*/

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.
*/
@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)

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

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

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.ReferencesAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.database.query.create
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.where
@ -114,8 +115,8 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst()
?.let {
TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findFirst()
?.let { tet -> tet.annotations = it }
TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll()
?.forEach { tet -> tet.annotations = it }
}
}
@ -193,6 +194,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
handleReaction(realm, event, roomId, isLocalEcho)
}
}
// HandleInitialAggregatedRelations should also be applied in encrypted messages with annotations
// else if (event.unsignedData?.relations?.annotations != null) {
// Timber.v("###REACTION e2e Aggregation in room $roomId for event ${event.eventId}")
// handleInitialAggregatedRelations(realm, event, roomId, event.unsignedData.relations.annotations)
// EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst()
// ?.let {
// TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll()
// ?.forEach { tet -> tet.annotations = it }
// }
// }
}
EventType.REDACTION -> {
val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() }
@ -240,7 +251,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
}
// OPT OUT serer aggregation until API mature enough
private val SHOULD_HANDLE_SERVER_AGREGGATION = false
private val SHOULD_HANDLE_SERVER_AGREGGATION = false // should be true to work with e2e
private fun handleReplace(realm: Realm,
event: Event,
@ -332,13 +343,18 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
}
if (!isLocalEcho) {
val replaceEvent = TimelineEventEntity.where(realm, roomId, eventId).findFirst()
val replaceEvent = TimelineEventEntity
.where(realm, roomId, eventId)
.equalTo(TimelineEventEntityFields.OWNED_BY_THREAD_CHUNK, false)
.findFirst()
handleThreadSummaryEdition(editedEvent, replaceEvent, existingSummary?.editions)
}
}
/**
* Check if the edition is on the latest thread event, and update it accordingly
* @param editedEvent The event that will be changed
* @param replaceEvent The new event
*/
private fun handleThreadSummaryEdition(editedEvent: EventEntity?,
replaceEvent: TimelineEventEntity?,

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

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

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

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.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor
import org.matrix.android.sdk.internal.session.room.timeline.TimelineEventDataSource
@ -48,7 +47,6 @@ internal class DefaultRelationService @AssistedInject constructor(
private val eventFactory: LocalEchoEventFactory,
private val findReactionEventForUndoTask: FindReactionEventForUndoTask,
private val fetchEditHistoryTask: FetchEditHistoryTask,
private val fetchThreadTimelineTask: FetchThreadTimelineTask,
private val timelineEventDataSource: TimelineEventDataSource,
@SessionDatabase private val monarchy: Monarchy
) : RelationService {
@ -196,10 +194,6 @@ internal class DefaultRelationService @AssistedInject constructor(
return eventSenderProcessor.postEvent(event)
}
override suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean {
return fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params(roomId, rootThreadEventId))
}
/**
* Saves the event in database as a local echo.
* SendState is set to UNSENT and it's added to a the sendingTimelineEvents list of the room.

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");
* you may not use this file except in compliance with the License.
@ -20,14 +20,12 @@ import io.realm.Realm
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider
import org.matrix.android.sdk.internal.crypto.DefaultCryptoService
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.helper.addTimelineEvent
import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded
import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.mapper.toEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntity
@ -36,8 +34,10 @@ import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEnt
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventInsertType
import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom
import org.matrix.android.sdk.internal.database.query.find
import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfThread
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.getOrNull
import org.matrix.android.sdk.internal.database.query.where
@ -47,16 +47,38 @@ import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent
import org.matrix.android.sdk.internal.session.room.RoomAPI
import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse
import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import timber.log.Timber
import javax.inject.Inject
internal interface FetchThreadTimelineTask : Task<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(
val roomId: String,
val rootThreadEventId: String
val rootThreadEventId: String,
val from: String?,
val limit: Int
)
}
@ -69,93 +91,129 @@ internal class DefaultFetchThreadTimelineTask @Inject constructor(
private val cryptoService: DefaultCryptoService
) : FetchThreadTimelineTask {
override suspend fun execute(params: FetchThreadTimelineTask.Params): Boolean {
val isRoomEncrypted = cryptoSessionInfoProvider.isRoomEncrypted(params.roomId)
enum class Result {
SHOULD_FETCH_MORE,
REACHED_END,
SUCCESS
}
override suspend fun execute(params: FetchThreadTimelineTask.Params): Result {
val response = executeRequest(globalErrorReceiver) {
roomAPI.getRelations(
roomAPI.getThreadsRelations(
roomId = params.roomId,
eventId = params.rootThreadEventId,
relationType = RelationType.IO_THREAD,
eventType = if (isRoomEncrypted) EventType.ENCRYPTED else EventType.MESSAGE,
limit = 2000
from = params.from,
limit = params.limit
)
}
val threadList = response.chunks + listOfNotNull(response.originalEvent)
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,
* a timeline update should be made
* @param threadList is the list containing the thread replies
* @param roomId the roomId of the the thread
* @return
* Create an EventEntity to be added in the TimelineEventEntity
*/
private suspend fun storeNewEventsIfNeeded(threadList: List<Event>, roomId: String): Boolean {
var eventsSkipped = 0
monarchy
.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
private fun createEventEntity(roomId: String, event: Event, realm: Realm): EventEntity {
val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it }
return event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.PAGINATION)
}
/**
* Invoke the event decryption mechanism for a specific event
*/
private suspend fun decryptIfNeeded(event: Event, roomId: String) {
try {
// Event from sync does not have roomId, so add it to the event first

View file

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

View file

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

View file

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

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.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask
import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler
import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler
import org.matrix.android.sdk.internal.task.SemaphoreCoroutineSequencer
@ -58,6 +59,7 @@ internal class DefaultTimeline(private val roomId: String,
paginationTask: PaginationTask,
getEventTask: GetContextOfEventTask,
fetchTokenAndPaginateTask: FetchTokenAndPaginateTask,
fetchThreadTimelineTask: FetchThreadTimelineTask,
timelineEventMapper: TimelineEventMapper,
timelineInput: TimelineInput,
threadsAwarenessHandler: ThreadsAwarenessHandler,
@ -89,7 +91,9 @@ internal class DefaultTimeline(private val roomId: String,
realm = backgroundRealm,
eventDecryptor = eventDecryptor,
paginationTask = paginationTask,
realmConfiguration = realmConfiguration,
fetchTokenAndPaginateTask = fetchTokenAndPaginateTask,
fetchThreadTimelineTask = fetchThreadTimelineTask,
getContextOfEventTask = getEventTask,
timelineInput = timelineInput,
timelineEventMapper = timelineEventMapper,
@ -297,7 +301,13 @@ internal class DefaultTimeline(private val roomId: String,
Timber.v("Post snapshot of ${snapshot.size} events")
withContext(coroutineDispatchers.main) {
listeners.forEach {
tryOrNull { it.onTimelineUpdated(snapshot) }
if (initialEventId != null && isFromThreadTimeline && snapshot.firstOrNull { it.eventId == initialEventId } == null) {
// We are in a thread timeline with a permalink, post update timeline only when the appropriate message have been found
tryOrNull { it.onTimelineUpdated(arrayListOf()) }
} else {
// In all the other cases update timeline as expected
tryOrNull { it.onTimelineUpdated(snapshot) }
}
}
}
}

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

View file

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

View file

@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room.timeline
import io.realm.OrderedCollectionChangeSet
import io.realm.OrderedRealmCollectionChangeListener
import io.realm.RealmConfiguration
import io.realm.RealmObjectChangeListener
import io.realm.RealmQuery
import io.realm.RealmResults
@ -36,6 +37,8 @@ import org.matrix.android.sdk.internal.database.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntityFields
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask
import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler
import timber.log.Timber
import java.util.Collections
@ -50,8 +53,10 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
private val timelineSettings: TimelineSettings,
private val roomId: String,
private val timelineId: String,
private val fetchThreadTimelineTask: FetchThreadTimelineTask,
private val eventDecryptor: TimelineEventDecryptor,
private val paginationTask: PaginationTask,
private val realmConfiguration: RealmConfiguration,
private val fetchTokenAndPaginateTask: FetchTokenAndPaginateTask,
private val timelineEventMapper: TimelineEventMapper,
private val uiEchoManager: UIEchoManager? = null,
@ -141,6 +146,9 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
val loadFromStorage = loadFromStorage(count, direction).also {
logLoadedFromStorage(it, direction)
}
if (loadFromStorage.numberOfEvents == 6) {
Timber.i("here")
}
val offsetCount = count - loadFromStorage.numberOfEvents
@ -157,6 +165,29 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
}
}
/**
* This function will fetch more live thread timeline events using the /relations api. It will
* always fetch results, while we want our data to be up to dated.
*/
suspend fun loadMoreThread(count: Int, direction: Timeline.Direction = Timeline.Direction.BACKWARDS): LoadMoreResult {
val rootThreadEventId = timelineSettings.rootThreadEventId ?: return LoadMoreResult.FAILURE
return if (direction == Timeline.Direction.BACKWARDS) {
try {
fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params(
roomId,
rootThreadEventId,
chunkEntity.prevToken,
count
)).toLoadMoreResult()
} catch (failure: Throwable) {
Timber.e(failure, "Failed to fetch thread timeline events from the server")
LoadMoreResult.FAILURE
}
} else {
LoadMoreResult.FAILURE
}
}
private suspend fun delegateLoadMore(fetchFromServerIfNeeded: Boolean, offsetCount: Int, direction: Timeline.Direction): LoadMoreResult {
return if (direction == Timeline.Direction.FORWARDS) {
val nextChunkEntity = chunkEntity.nextChunk
@ -413,6 +444,14 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
}
}
private fun DefaultFetchThreadTimelineTask.Result.toLoadMoreResult(): LoadMoreResult {
return when (this) {
DefaultFetchThreadTimelineTask.Result.REACHED_END -> LoadMoreResult.REACHED_END
DefaultFetchThreadTimelineTask.Result.SHOULD_FETCH_MORE,
DefaultFetchThreadTimelineTask.Result.SUCCESS -> LoadMoreResult.SUCCESS
}
}
private fun getOffsetIndex(): Int {
var offset = 0
var currentNextChunk = nextChunk
@ -454,6 +493,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
}
}
}
if (insertions.isNotEmpty() || modifications.isNotEmpty()) {
onBuiltEvents(true)
}
@ -487,6 +527,8 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
timelineId = timelineId,
eventDecryptor = eventDecryptor,
paginationTask = paginationTask,
realmConfiguration = realmConfiguration,
fetchThreadTimelineTask = fetchThreadTimelineTask,
fetchTokenAndPaginateTask = fetchTokenAndPaginateTask,
timelineEventMapper = timelineEventMapper,
uiEchoManager = uiEchoManager,
@ -538,7 +580,6 @@ private fun ChunkEntity.sortedTimelineEvents(rootThreadEventId: String?): RealmR
.or()
.equalTo(TimelineEventEntityFields.ROOT.EVENT_ID, rootThreadEventId)
.endGroup()
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
.findAll()
}
}

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

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.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
import org.matrix.android.sdk.api.session.initsync.InitSyncStep
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummaryUpdateType
import org.matrix.android.sdk.api.session.sync.model.InvitedRoomSync
import org.matrix.android.sdk.api.session.sync.model.LazyRoomSyncEphemeral
import org.matrix.android.sdk.api.session.sync.model.RoomSync
@ -37,6 +39,7 @@ import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.helper.addIfNecessary
import org.matrix.android.sdk.internal.database.helper.addTimelineEvent
import org.matrix.android.sdk.internal.database.helper.createOrUpdate
import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded
import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage
import org.matrix.android.sdk.internal.database.mapper.asDomain
@ -47,10 +50,13 @@ import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventInsertType
import org.matrix.android.sdk.internal.database.model.RoomEntity
import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.deleteOnCascade
import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.database.query.find
import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom
import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfThread
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.getOrNull
import org.matrix.android.sdk.internal.database.query.where
@ -85,6 +91,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
private val threadsAwarenessHandler: ThreadsAwarenessHandler,
private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource,
@UserId private val userId: String,
private val homeServerCapabilitiesService: HomeServerCapabilitiesService,
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val timelineInput: TimelineInput,
private val liveEventService: Lazy<StreamEventsManager>) {
@ -95,11 +102,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
data class LEFT(val data: Map<String, RoomSync>) : HandlingStrategy()
}
fun handle(realm: Realm,
roomsSyncResponse: RoomsSyncResponse,
isInitialSync: Boolean,
aggregator: SyncResponsePostTreatmentAggregator,
reporter: ProgressReporter? = null) {
suspend fun handle(realm: Realm,
roomsSyncResponse: RoomsSyncResponse,
isInitialSync: Boolean,
aggregator: SyncResponsePostTreatmentAggregator,
reporter: ProgressReporter? = null) {
Timber.v("Execute transaction from $this")
handleRoomSync(realm, HandlingStrategy.JOINED(roomsSyncResponse.join), isInitialSync, aggregator, reporter)
handleRoomSync(realm, HandlingStrategy.INVITED(roomsSyncResponse.invite), isInitialSync, aggregator, reporter)
@ -114,11 +121,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
}
// PRIVATE METHODS *****************************************************************************
private fun handleRoomSync(realm: Realm,
handlingStrategy: HandlingStrategy,
isInitialSync: Boolean,
aggregator: SyncResponsePostTreatmentAggregator,
reporter: ProgressReporter?) {
private suspend fun handleRoomSync(realm: Realm,
handlingStrategy: HandlingStrategy,
isInitialSync: Boolean,
aggregator: SyncResponsePostTreatmentAggregator,
reporter: ProgressReporter?) {
val insertType = if (isInitialSync) {
EventInsertType.INITIAL_SYNC
} else {
@ -151,11 +158,11 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
realm.insertOrUpdate(rooms)
}
private fun insertJoinRoomsFromInitSync(realm: Realm,
handlingStrategy: HandlingStrategy.JOINED,
syncLocalTimeStampMillis: Long,
aggregator: SyncResponsePostTreatmentAggregator,
reporter: ProgressReporter?) {
private suspend fun insertJoinRoomsFromInitSync(realm: Realm,
handlingStrategy: HandlingStrategy.JOINED,
syncLocalTimeStampMillis: Long,
aggregator: SyncResponsePostTreatmentAggregator,
reporter: ProgressReporter?) {
val bestChunkSize = computeBestChunkSize(
listSize = handlingStrategy.data.keys.size,
limit = (initialSyncStrategy as? InitialSyncStrategy.Optimized)?.maxRoomsToInsert ?: Int.MAX_VALUE
@ -193,12 +200,12 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
}
}
private fun handleJoinedRoom(realm: Realm,
roomId: String,
roomSync: RoomSync,
insertType: EventInsertType,
syncLocalTimestampMillis: Long,
aggregator: SyncResponsePostTreatmentAggregator): RoomEntity {
private suspend fun handleJoinedRoom(realm: Realm,
roomId: String,
roomSync: RoomSync,
insertType: EventInsertType,
syncLocalTimestampMillis: Long,
aggregator: SyncResponsePostTreatmentAggregator): RoomEntity {
Timber.v("Handle join sync for room $roomId")
val ephemeralResult = (roomSync.ephemeral as? LazyRoomSyncEphemeral.Parsed)
@ -344,15 +351,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
return roomEntity
}
private fun handleTimelineEvents(realm: Realm,
roomId: String,
roomEntity: RoomEntity,
eventList: List<Event>,
prevToken: String? = null,
isLimited: Boolean = true,
insertType: EventInsertType,
syncLocalTimestampMillis: Long,
aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity {
private suspend fun handleTimelineEvents(realm: Realm,
roomId: String,
roomEntity: RoomEntity,
eventList: List<Event>,
prevToken: String? = null,
isLimited: Boolean = true,
insertType: EventInsertType,
syncLocalTimestampMillis: Long,
aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity {
val lastChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomEntity.roomId)
if (isLimited && lastChunk != null) {
lastChunk.deleteOnCascade(deleteStateEvents = false, canDeleteRoot = true)
@ -409,11 +416,28 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
rootStateEvent?.asDomain()?.getFixedRoomMemberContent()
}
chunkEntity.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser)
val timelineEventAdded = chunkEntity.addTimelineEvent(
roomId = roomId,
eventEntity = eventEntity,
direction = PaginationDirection.FORWARDS,
roomMemberContentsByUser = roomMemberContentsByUser)
if (lightweightSettingsStorage.areThreadMessagesEnabled()) {
eventEntity.rootThreadEventId?.let {
// This is a thread event
optimizedThreadSummaryMap[it] = eventEntity
// Add the same thread timeline event to Thread Chunk
addToThreadChunkIfNeeded(realm, roomId, it, timelineEventAdded, roomEntity)
if (homeServerCapabilitiesService.getHomeServerCapabilities().canUseThreading) {
// Update thread summaries only if homeserver supports threading
ThreadSummaryEntity.createOrUpdate(
threadSummaryType = ThreadSummaryUpdateType.ADD,
realm = realm,
roomId = roomId,
threadEventEntity = eventEntity,
roomMemberContentsByUser = roomMemberContentsByUser,
userId = userId,
roomEntity = roomEntity)
}
} ?: run {
// This is a normal event or a root thread one
optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity
@ -458,6 +482,28 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
return chunkEntity
}
/**
* Adds new event to the appropriate thread chunk. If the event is already in
* the thread timeline and /relations api, we should not added it
*/
private fun addToThreadChunkIfNeeded(realm: Realm,
roomId: String,
threadId: String,
timelineEventEntity: TimelineEventEntity?,
roomEntity: RoomEntity) {
val eventId = timelineEventEntity?.eventId ?: return
ChunkEntity.findLastForwardChunkOfThread(realm, roomId, threadId)?.let { threadChunk ->
val existingEvent = threadChunk.timelineEvents.find(eventId)
if (existingEvent?.ownedByThreadChunk == true) {
Timber.i("###THREADS RoomSyncHandler event:${timelineEventEntity.eventId} already exists, do not add")
return@addToThreadChunkIfNeeded
}
threadChunk.timelineEvents.add(0, timelineEventEntity)
roomEntity.addIfNecessary(threadChunk)
}
}
private suspend fun decryptIfNeeded(event: Event, roomId: String) {
try {
// Event from sync does not have roomId, so add it to the event first

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

View file

@ -27,6 +27,7 @@ import im.vector.app.espresso.tools.ScreenshotFailureRule
import im.vector.app.features.MainActivity
import im.vector.app.getString
import im.vector.app.ui.robot.ElementRobot
import im.vector.app.ui.robot.settings.labs.LabFeature
import im.vector.app.ui.robot.withDeveloperMode
import org.junit.Rule
import org.junit.Test
@ -97,6 +98,8 @@ class UiAllScreensSanityTest {
}
}
testThreadScreens()
elementRobot.space {
createSpace {
crawl()
@ -148,4 +151,25 @@ class UiAllScreensSanityTest {
// TODO Deactivate account instead of logout?
elementRobot.signout(expectSignOutWarning = false)
}
/**
* Testing multiple threads screens
*/
private fun testThreadScreens() {
elementRobot.toggleLabFeature(LabFeature.THREAD_MESSAGES)
elementRobot.newRoom {
createNewRoom {
crawl()
createRoom {
val message = "Hello This message will be a thread!"
postMessage(message)
replyToThread(message)
viewInRoom(message)
openThreadSummaries()
selectThreadSummariesFilter()
}
}
}
elementRobot.toggleLabFeature(LabFeature.THREAD_MESSAGES)
}
}

View file

@ -17,9 +17,15 @@
package im.vector.app.ui.robot
import android.view.View
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.pressBack
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions.assertDisplayed
import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn
import com.adevinta.android.barista.interaction.BaristaDialogInteractions.clickDialogNegativeButton
@ -35,6 +41,7 @@ import im.vector.app.features.home.HomeActivity
import im.vector.app.features.onboarding.OnboardingActivity
import im.vector.app.initialSyncIdlingResource
import im.vector.app.ui.robot.settings.SettingsRobot
import im.vector.app.ui.robot.settings.labs.LabFeature
import im.vector.app.ui.robot.space.SpaceRobot
import im.vector.app.withIdlingResource
import timber.log.Timber
@ -70,11 +77,11 @@ class ElementRobot {
}
}
fun settings(block: SettingsRobot.() -> Unit) {
fun settings(shouldGoBack: Boolean = true, block: SettingsRobot.() -> Unit) {
openDrawer()
clickOn(R.id.homeDrawerHeaderSettingsView)
block(SettingsRobot())
pressBack()
if (shouldGoBack) pressBack()
waitUntilViewVisible(withId(R.id.bottomNavigationView))
}
@ -103,6 +110,22 @@ class ElementRobot {
waitUntilViewVisible(withId(R.id.bottomNavigationView))
}
fun toggleLabFeature(labFeature: LabFeature) {
when (labFeature) {
LabFeature.THREAD_MESSAGES -> {
settings(shouldGoBack = false) {
labs(shouldGoBack = false) {
onView(withText(R.string.labs_enable_thread_messages))
.check(ViewAssertions.matches(isDisplayed()))
.perform(ViewActions.closeSoftKeyboard(), click())
}
}
}
else -> {
}
}
}
fun signout(expectSignOutWarning: Boolean) {
clickOn(R.id.groupToolbarAvatarImageView)
clickOn(R.id.homeDrawerHeaderSignoutView)

View file

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

View file

@ -62,6 +62,23 @@ class RoomDetailRobot {
pressBack()
}
fun replyToThread(message: String) {
openMessageMenu(message) {
replyInThread()
}
val threadMessage = "Hello universe - long message to avoid espresso tapping edited!"
writeTo(R.id.composerEditText, threadMessage)
waitUntilViewVisible(withId(R.id.sendButton))
clickOn(R.id.sendButton)
}
fun viewInRoom(message: String) {
openMessageMenu(message) {
viewInRoom()
}
waitUntilViewVisible(withId(R.id.composerEditText))
}
fun crawlMessage(message: String) {
// Test quick reaction
val quickReaction = EmojiDataSource.quickEmojis[0] // 👍
@ -110,7 +127,7 @@ class RoomDetailRobot {
onView(withId(R.id.timelineRecyclerView))
.perform(
RecyclerViewActions.actionOnItem<RecyclerView.ViewHolder>(
ViewMatchers.hasDescendant(ViewMatchers.withText(message)),
ViewMatchers.hasDescendant(withText(message)),
ViewActions.longClick()
)
)
@ -130,4 +147,16 @@ class RoomDetailRobot {
block(RoomSettingsRobot())
pressBack()
}
fun openThreadSummaries() {
clickMenu(R.id.menu_timeline_thread_list)
waitUntilViewVisible(withId(R.id.threadListRecyclerView))
}
fun selectThreadSummariesFilter() {
clickMenu(R.id.menu_thread_list_filter)
sleep(1000)
clickOn(R.id.threadListModalMyThreads)
pressBack()
}
}

View file

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

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() {
const val PAGINATION_COUNT = 50
// The larger the number the faster the results, COUNT=200 for 500 thread messages its x4 faster than COUNT=50
const val PAGINATION_COUNT_THREADS_PERMALINK = 200
}
init {
@ -503,7 +506,10 @@ class TimelineViewModel @AssistedInject constructor(
private fun handleSendSticker(action: RoomDetailAction.SendSticker) {
val content = initialState.rootThreadEventId?.let {
action.stickerContent.copy(relatesTo = RelationDefaultContent(RelationType.IO_THREAD, it))
action.stickerContent.copy(relatesTo = RelationDefaultContent(
type = RelationType.THREAD,
isFallingBack = true,
eventId = it))
} ?: action.stickerContent
room.sendEvent(EventType.STICKER, content.toContent())
@ -1175,10 +1181,30 @@ class TimelineViewModel @AssistedInject constructor(
}
}
/**
* Navigates to the appropriate event (by paginating the thread timeline until the event is found
* in the snapshot. The main reason for this function is to support the /relations api
*/
private var threadPermalinkHandled = false
private fun navigateToThreadEventIfNeeded(snapshot: List<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>) {
viewModelScope.launch {
// tryEmit doesn't work with SharedFlow without cache
timelineEvents.emit(snapshot)
navigateToThreadEventIfNeeded(snapshot)
}
}

View file

@ -465,7 +465,8 @@ class MessageComposerViewModel @AssistedInject constructor(
// is original event a reply?
val relationContent = state.sendMode.timelineEvent.getRelationContent()
val inReplyTo = if (state.rootThreadEventId != null) {
if (relationContent?.inReplyTo?.shouldRenderInThread() == true) {
// Thread event
if (relationContent?.shouldRenderInThread() == true) {
// Reply within a thread event
relationContent.inReplyTo?.eventId
} else {
@ -509,6 +510,7 @@ class MessageComposerViewModel @AssistedInject constructor(
is SendMode.Reply -> {
val timelineEvent = state.sendMode.timelineEvent
val showInThread = state.sendMode.timelineEvent.root.isThread() && state.rootThreadEventId == null
// If threads are disabled this will make the fallback replies visible to clients with threads enabled
val rootThreadEventId = if (showInThread) timelineEvent.root.getRootThreadEventId() else null
state.rootThreadEventId?.let {
room.replyInThread(

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

View file

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

View file

@ -24,9 +24,11 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.features.html.EventHtmlRenderer
import me.gujun.android.span.span
import org.commonmark.node.Document
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
@ -139,6 +141,98 @@ class DisplayableEventFormatter @Inject constructor(
}
}
fun formatThreadSummary(
event: Event?,
latestEdition: String? = null): CharSequence {
event ?: return ""
// There event have been edited
if (latestEdition != null) {
return run {
val localFormattedBody = htmlRenderer.get().parse(latestEdition) as Document
val renderedBody = htmlRenderer.get().render(localFormattedBody) ?: latestEdition
renderedBody
}
}
// The event have been redacted
if (event.isRedacted()) {
return noticeEventFormatter.formatRedactedEvent(event)
}
// The event is encrypted
if (event.isEncrypted() &&
event.mxDecryptionResult == null) {
return stringProvider.getString(R.string.encrypted_message)
}
return when (event.getClearType()) {
EventType.MESSAGE -> {
(event.getClearContent().toModel() as? MessageContent)?.let { messageContent ->
when (messageContent.msgType) {
MessageType.MSGTYPE_TEXT -> {
val body = messageContent.getTextDisplayableContent()
if (messageContent is MessageTextContent && messageContent.matrixFormattedBody.isNullOrBlank().not()) {
val localFormattedBody = htmlRenderer.get().parse(body) as Document
val renderedBody = htmlRenderer.get().render(localFormattedBody) ?: body
renderedBody
} else {
body
}
}
MessageType.MSGTYPE_VERIFICATION_REQUEST -> {
stringProvider.getString(R.string.verification_request)
}
MessageType.MSGTYPE_IMAGE -> {
stringProvider.getString(R.string.sent_an_image)
}
MessageType.MSGTYPE_AUDIO -> {
if ((messageContent as? MessageAudioContent)?.voiceMessageIndicator != null) {
stringProvider.getString(R.string.sent_a_voice_message)
} else {
stringProvider.getString(R.string.sent_an_audio_file)
}
}
MessageType.MSGTYPE_VIDEO -> {
stringProvider.getString(R.string.sent_a_video)
}
MessageType.MSGTYPE_FILE -> {
stringProvider.getString(R.string.sent_a_file)
}
MessageType.MSGTYPE_LOCATION -> {
stringProvider.getString(R.string.sent_location)
}
else -> {
messageContent.body
}
}
} ?: span { }
}
EventType.STICKER -> {
stringProvider.getString(R.string.send_a_sticker)
}
EventType.REACTION -> {
event.getClearContent().toModel<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 {
return if (appendAuthor) {
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.room.detail.timeline.MessageColorProvider
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryEvents
@ -33,6 +34,7 @@ class MessageItemAttributesFactory @Inject constructor(
private val messageColorProvider: MessageColorProvider,
private val avatarSizeProvider: AvatarSizeProvider,
private val stringProvider: StringProvider,
private val displayableEventFormatter: DisplayableEventFormatter,
private val preferencesProvider: UserPreferencesProvider,
private val emojiCompatFontProvider: EmojiCompatFontProvider) {
@ -61,6 +63,7 @@ class MessageItemAttributesFactory @Inject constructor(
readReceiptsCallback = callback,
emojiTypeFace = emojiCompatFontProvider.typeface,
decryptionErrorMessage = stringProvider.getString(R.string.encrypted_message),
threadSummaryFormatted = displayableEventFormatter.formatThreadSummary(threadDetails?.threadSummaryLatestEvent).toString(),
threadDetails = threadDetails,
reactionsSummaryEvents = reactionsSummaryEvents,
areThreadMessagesEnabled = preferencesProvider.areThreadMessagesEnabled()

View file

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

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

View file

@ -17,21 +17,26 @@
package im.vector.app.features.home.room.threads.list.viewmodel
import com.airbnb.epoxy.EpoxyController
import im.vector.app.R
import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter
import im.vector.app.features.home.room.threads.list.model.threadListItem
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.threads.ThreadNotificationState
import org.matrix.android.sdk.api.util.toMatrixItem
import org.matrix.android.sdk.api.util.toMatrixItemOrNull
import javax.inject.Inject
class ThreadListController @Inject constructor(
private val avatarRenderer: AvatarRenderer,
private val stringProvider: StringProvider,
private val dateFormatter: VectorDateFormatter
private val dateFormatter: VectorDateFormatter,
private val displayableEventFormatter: DisplayableEventFormatter,
private val session: Session
) : EpoxyController() {
var listener: Listener? = null
@ -43,10 +48,68 @@ class ThreadListController @Inject constructor(
requestModelBuild()
}
override fun buildModels() {
override fun buildModels() =
when (session.getHomeServerCapabilities().canUseThreading) {
true -> buildThreadSummaries()
false -> buildThreadList()
}
/**
* Building thread summaries when homeserver
* supports threading
*/
private fun buildThreadSummaries() {
val safeViewState = viewState ?: return
val host = this
safeViewState.threadSummaryList.invoke()
?.filter {
if (safeViewState.shouldFilterThreads) {
it.isUserParticipating
} else {
true
}
}
?.forEach { threadSummary ->
val date = dateFormatter.format(threadSummary.latestEvent?.originServerTs, DateFormatKind.ROOM_LIST)
val lastMessageFormatted = threadSummary.let {
displayableEventFormatter.formatThreadSummary(
event = it.latestEvent,
latestEdition = it.threadEditions.latestThreadEdition
).toString()
}
val rootMessageFormatted = threadSummary.let {
displayableEventFormatter.formatThreadSummary(
event = it.rootEvent,
latestEdition = it.threadEditions.rootThreadEdition
).toString()
}
threadListItem {
id(threadSummary.rootEvent?.eventId)
avatarRenderer(host.avatarRenderer)
matrixItem(threadSummary.rootThreadSenderInfo.toMatrixItem())
title(threadSummary.rootThreadSenderInfo.displayName.orEmpty())
date(date)
rootMessageDeleted(threadSummary.rootEvent?.isRedacted() ?: false)
// TODO refactor notifications that with the new thread summary
threadNotificationState(threadSummary.rootEvent?.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE)
rootMessage(rootMessageFormatted)
lastMessage(lastMessageFormatted)
lastMessageCounter(threadSummary.numberOfThreads.toString())
lastMessageMatrixItem(threadSummary.latestThreadSenderInfo.toMatrixItemOrNull())
itemClickListener {
host.listener?.onThreadSummaryClicked(threadSummary)
}
}
}
}
/**
* Building local thread list when homeserver do not
* support threading
*/
private fun buildThreadList() {
val safeViewState = viewState ?: return
val host = this
safeViewState.rootThreadEventList.invoke()
?.filter {
if (safeViewState.shouldFilterThreads) {
@ -59,28 +122,39 @@ class ThreadListController @Inject constructor(
}
?.forEach { timelineEvent ->
val date = dateFormatter.format(timelineEvent.root.threadDetails?.lastMessageTimestamp, DateFormatKind.ROOM_LIST)
val decryptionErrorMessage = stringProvider.getString(R.string.encrypted_message)
val lastRootThreadEdition = timelineEvent.root.threadDetails?.lastRootThreadEdition
val lastMessageFormatted = timelineEvent.root.threadDetails?.threadSummaryLatestEvent.let {
displayableEventFormatter.formatThreadSummary(
event = it,
).toString()
}
val rootMessageFormatted = timelineEvent.root.let {
displayableEventFormatter.formatThreadSummary(
event = it,
latestEdition = lastRootThreadEdition
).toString()
}
threadListItem {
id(timelineEvent.eventId)
avatarRenderer(host.avatarRenderer)
matrixItem(timelineEvent.senderInfo.toMatrixItem())
title(timelineEvent.senderInfo.displayName)
title(timelineEvent.senderInfo.displayName.orEmpty())
date(date)
rootMessageDeleted(timelineEvent.root.isRedacted())
threadNotificationState(timelineEvent.root.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE)
rootMessage(lastRootThreadEdition ?: timelineEvent.root.getDecryptedTextSummary() ?: decryptionErrorMessage)
lastMessage(timelineEvent.root.threadDetails?.threadSummaryLatestTextMessage ?: decryptionErrorMessage)
rootMessage(rootMessageFormatted)
lastMessage(lastMessageFormatted)
lastMessageCounter(timelineEvent.root.threadDetails?.numberOfThreads.toString())
lastMessageMatrixItem(timelineEvent.root.threadDetails?.threadSummarySenderInfo?.toMatrixItem())
itemClickListener {
host.listener?.onThreadClicked(timelineEvent)
host.listener?.onThreadListClicked(timelineEvent)
}
}
}
}
interface Listener {
fun onThreadClicked(timelineEvent: TimelineEvent)
fun onThreadSummaryClicked(threadSummary: ThreadSummary)
fun onThreadListClicked(timelineEvent: TimelineEvent)
}
}

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 kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent
import org.matrix.android.sdk.flow.flow
@ -53,11 +54,43 @@ class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState
}
init {
observeThreadsList()
fetchAndObserveThreads()
}
override fun handle(action: EmptyAction) {}
/**
* Observing thread list with respect to homeserver
* capabilities
*/
private fun fetchAndObserveThreads() {
when (session.getHomeServerCapabilities().canUseThreading) {
true -> {
fetchThreadList()
observeThreadSummaries()
}
false -> observeThreadsList()
}
}
/**
* Observing thread summaries when homeserver support
* threading
*/
private fun observeThreadSummaries() {
room?.flow()
?.liveThreadSummaries()
?.map { room.enhanceThreadWithEditions(it) }
?.flowOn(room.coroutineDispatchers.io)
?.execute { asyncThreads ->
copy(threadSummaryList = asyncThreads)
}
}
/**
* Observing thread list when homeserver do not support
* threading
*/
private fun observeThreadsList() {
room?.flow()
?.liveThreadList()
@ -74,6 +107,14 @@ class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState
}
}
private fun fetchThreadList() {
viewModelScope.launch {
room?.fetchThreadSummaries()
}
}
fun canHomeserverUseThreading() = session.getHomeServerCapabilities().canUseThreading
fun applyFiltering(shouldFilterThreads: Boolean) {
setState {
copy(shouldFilterThreads = shouldFilterThreads)

View file

@ -20,13 +20,14 @@ import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import im.vector.app.features.home.room.threads.arguments.ThreadListArgs
import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent
data class ThreadListViewState(
val threadSummaryList: Async<List<ThreadSummary>> = Uninitialized,
val rootThreadEventList: Async<List<ThreadTimelineEvent>> = Uninitialized,
val shouldFilterThreads: Boolean = false,
val roomId: String
) : MavericksState {
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.threads.ThreadsActivity
import im.vector.app.features.home.room.threads.arguments.ThreadListArgs
import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs
import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListController
import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewModel
import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewState
import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.MatrixItem
import javax.inject.Inject
@ -111,12 +113,30 @@ class ThreadListFragment @Inject constructor(
views.includeThreadListToolbar.roomToolbarThreadSubtitleTextView.text = threadListArgs.displayName
}
override fun onThreadClicked(timelineEvent: TimelineEvent) {
(activity as? ThreadsActivity)?.navigateToThreadTimeline(timelineEvent)
override fun onThreadSummaryClicked(threadSummary: ThreadSummary) {
val roomThreadDetailArgs = ThreadTimelineArgs(
roomId = threadSummary.roomId,
displayName = threadSummary.rootThreadSenderInfo.displayName,
avatarUrl = threadSummary.rootThreadSenderInfo.avatarUrl,
roomEncryptionTrustLevel = null,
rootThreadEventId = threadSummary.rootEventId)
(activity as? ThreadsActivity)?.navigateToThreadTimeline(roomThreadDetailArgs)
}
override fun onThreadListClicked(timelineEvent: TimelineEvent) {
val threadTimelineArgs = ThreadTimelineArgs(
roomId = timelineEvent.roomId,
displayName = timelineEvent.senderInfo.displayName,
avatarUrl = timelineEvent.senderInfo.avatarUrl,
roomEncryptionTrustLevel = null,
rootThreadEventId = timelineEvent.eventId)
(activity as? ThreadsActivity)?.navigateToThreadTimeline(threadTimelineArgs)
}
private fun renderEmptyStateIfNeeded(state: ThreadListViewState) {
val show = state.rootThreadEventList.invoke().isNullOrEmpty()
views.threadListEmptyConstraintLayout.isVisible = show
when (threadListViewModel.canHomeserverUseThreading()) {
true -> views.threadListEmptyConstraintLayout.isVisible = state.threadSummaryList.invoke().isNullOrEmpty()
false -> views.threadListEmptyConstraintLayout.isVisible = state.rootThreadEventList.invoke().isNullOrEmpty()
}
}
}