- Thread Summary along with optimization

- Create new thread & reply to thread
This commit is contained in:
ariskotsomitopoulos 2021-11-15 19:17:13 +02:00
parent ecc9b59ad1
commit 8c539426e6
40 changed files with 481 additions and 264 deletions

View file

@ -147,6 +147,11 @@ project(":diff-match-patch") {
}
}
// Global configurations across all modules
ext {
isThreadingEnabled = true
}
//project(":matrix-sdk-android") {
// sonarqube {
// properties {

View file

@ -38,6 +38,8 @@ android {
resValue "string", "git_sdk_revision_unix_date", "\"${gitRevisionUnixDate()}\""
resValue "string", "git_sdk_revision_date", "\"${gitRevisionDate()}\""
// Indicates whether or not threading support is enabled
buildConfigField "Boolean", "THREADING_ENABLED", "${isThreadingEnabled}"
defaultConfig {
consumerProguardFiles 'proguard-rules.pro'
}

View file

@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContent
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.threads.ThreadDetails
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent
@ -97,7 +98,7 @@ data class Event(
var sendStateDetails: String? = null
@Transient
var isRootThread: Boolean = false
var threadDetails: ThreadDetails? = null
fun sendStateError(): MatrixError? {
return sendStateDetails?.let {
@ -124,6 +125,7 @@ data class Event(
it.mCryptoErrorReason = mCryptoErrorReason
it.sendState = sendState
it.ageLocalTs = ageLocalTs
it.threadDetails = threadDetails
}
}
@ -186,6 +188,16 @@ data class Event(
return contentMap?.let { JSONObject(adapter.toJson(it)).toString(4) }
}
fun getDecryptedMessageText(): String {
return getValueFromPayload(mxDecryptionResult?.payload).orEmpty()
}
@Suppress("UNCHECKED_CAST")
private fun getValueFromPayload(payload: JsonDict?, key: String = "body"): String? {
val content = payload?.get("content") as? JsonDict
return content?.get(key) as? String
}
/**
* Tells if the event is redacted
*/
@ -218,7 +230,7 @@ data class Event(
if (mCryptoError != other.mCryptoError) return false
if (mCryptoErrorReason != other.mCryptoErrorReason) return false
if (sendState != other.sendState) return false
if (threadDetails != other.threadDetails) return false
return true
}
@ -237,6 +249,8 @@ data class Event(
result = 31 * result + (mCryptoError?.hashCode() ?: 0)
result = 31 * result + (mCryptoErrorReason?.hashCode() ?: 0)
result = 31 * result + sendState.hashCode()
result = 31 * result + threadDetails.hashCode()
return result
}
}

View file

@ -43,7 +43,7 @@ interface Timeline {
/**
* This must be called before any other method after creating the timeline. It ensures the underlying database is open
*/
fun start()
fun start(rootThreadEventId: String? = null)
/**
* This must be called when you don't need the timeline. It ensures the underlying database get closed.

View file

@ -0,0 +1,26 @@
/*
* Copyright 2021 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.threads
import org.matrix.android.sdk.api.session.room.sender.SenderInfo
data class ThreadDetails(
val isRootThread: Boolean = false,
val numberOfThreads: Int = 0,
val threadSummarySenderInfo: SenderInfo? = null,
val threadSummaryLatestTextMessage: String? = null
)

View file

@ -369,10 +369,12 @@ internal object RealmSessionStoreMigration : RealmMigration {
private fun migrateTo19(realm: DynamicRealm) {
Timber.d("Step 18 -> 19")
val eventEntity = realm.schema.get("TimelineEventEntity") ?: return
realm.schema.get("EventEntity")
?.addField(EventEntityFields.IS_THREAD, Boolean::class.java, FieldAttribute.INDEXED)
?.addField(EventEntityFields.ROOT_THREAD_EVENT_ID, String::class.java)
realm.schema.get("ChunkEntity")
?.addField(ChunkEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED)
?.addField(EventEntityFields.IS_ROOT_THREAD, Boolean::class.java, FieldAttribute.INDEXED)
?.addField(EventEntityFields.ROOT_THREAD_EVENT_ID, String::class.java, FieldAttribute.INDEXED)
?.addField(EventEntityFields.NUMBER_OF_THREADS, Int::class.java)
?.addRealmObjectField(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.`$`, eventEntity)
}
}

View file

@ -0,0 +1,88 @@
/*
* Copyright 2021 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.RealmResults
import io.realm.Sort
import org.matrix.android.sdk.BuildConfig
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.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.database.query.whereRoomId
/**
* Finds the root thread event and update it with the latest message summary along with the number
* of threads included. If there is no root thread event no action is done
*/
internal fun Map<String, EventEntity>.updateThreadSummaryIfNeeded() {
if (!BuildConfig.THREADING_ENABLED) return
for ((rootThreadEventId, eventEntity) in this) {
eventEntity.findAllThreadsForRootEventId(eventEntity.realm, rootThreadEventId).let {
if (it.isNullOrEmpty()) return@let
val latestMessage = it.firstOrNull()
// If this is a thread message, find its root event if exists
val rootThreadEvent = if (eventEntity.isThread()) eventEntity.findRootThreadEvent() else eventEntity
rootThreadEvent?.markEventAsRoot(
threadsCounted = it.size,
latestMessageTimelineEventEntity = latestMessage
)
}
}
}
/**
* Finds the root event of the the current thread event message.
* Returns the EventEntity or null if the root event do not exist
*/
internal fun EventEntity.findRootThreadEvent(): EventEntity? =
rootThreadEventId?.let {
EventEntity
.where(realm, it)
.findFirst()
}
/**
* Mark or update the current event a root thread event
*/
internal fun EventEntity.markEventAsRoot(threadsCounted: Int,
latestMessageTimelineEventEntity: TimelineEventEntity?) {
isRootThread = true
numberOfThreads = threadsCounted
threadSummaryLatestMessage = latestMessageTimelineEventEntity
}
/**
* Find all TimelineEventEntity that are threads bind to the Event with rootThreadEventId
* @param rootThreadEventId The root eventId that will try to find bind threads
*/
internal fun EventEntity.findAllThreadsForRootEventId(realm: Realm, rootThreadEventId: String): RealmResults<TimelineEventEntity> =
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findAll()

View file

@ -24,6 +24,9 @@ import org.matrix.android.sdk.api.session.events.model.UnsignedData
import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId
import org.matrix.android.sdk.api.session.events.model.isThread
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.room.sender.SenderInfo
import org.matrix.android.sdk.api.session.threads.ThreadDetails
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.di.MoshiProvider
@ -41,8 +44,6 @@ internal object EventMapper {
eventEntity.isUseless = IsUselessResolver.isUseless(event)
eventEntity.stateKey = event.stateKey
eventEntity.type = event.type ?: EventType.MISSING_TYPE
eventEntity.isThread = if(event.isRootThread) true else event.isThread()
eventEntity.rootThreadEventId = if(event.isRootThread) null else event.getRootThreadEventId()
eventEntity.sender = event.senderId
eventEntity.originServerTs = event.originServerTs
eventEntity.redacts = event.redacts
@ -55,6 +56,9 @@ internal object EventMapper {
}
eventEntity.decryptionErrorReason = event.mCryptoErrorReason
eventEntity.decryptionErrorCode = event.mCryptoError?.name
eventEntity.isRootThread = event.threadDetails?.isRootThread ?: false
eventEntity.rootThreadEventId = event.getRootThreadEventId()
eventEntity.numberOfThreads = event.threadDetails?.numberOfThreads ?: 0
return eventEntity
}
@ -97,7 +101,20 @@ internal object EventMapper {
MXCryptoError.ErrorType.valueOf(errorCode)
}
it.mCryptoErrorReason = eventEntity.decryptionErrorReason
it.isRootThread = eventEntity.isRootThread()
it.threadDetails = ThreadDetails(
isRootThread = eventEntity.isRootThread,
numberOfThreads = eventEntity.numberOfThreads,
threadSummarySenderInfo = eventEntity.threadSummaryLatestMessage?.let { timelineEventEntity ->
SenderInfo(
userId = timelineEventEntity.root?.sender ?: "",
displayName = timelineEventEntity.senderName,
isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName,
avatarUrl = timelineEventEntity.senderAvatar
)
},
threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedMessageText().orEmpty()
)
}
}
}

View file

@ -27,7 +27,6 @@ import org.matrix.android.sdk.internal.extensions.clearWith
internal open class ChunkEntity(@Index var prevToken: String? = null,
// Because of gaps we can have several chunks with nextToken == null
@Index var nextToken: String? = null,
@Index var rootThreadEventId: String? = null,
var stateEvents: RealmList<EventEntity> = RealmList(),
var timelineEvents: RealmList<TimelineEventEntity> = RealmList(),
var numberOfTimelineEvents: Long = 0,
@ -46,7 +45,6 @@ internal open class ChunkEntity(@Index var prevToken: String? = null,
companion object
fun isThreadChunk() = rootThreadEventId != null
}
internal fun ChunkEntity.deleteOnCascade(deleteStateEvents: Boolean, canDeleteRoot: Boolean) {

View file

@ -27,15 +27,13 @@ import org.matrix.android.sdk.internal.extensions.assertIsManaged
internal open class EventEntity(@Index var eventId: String = "",
@Index var roomId: String = "",
@Index var type: String = "",
@Index var isThread: Boolean = false,
var rootThreadEventId: String? = null,
var content: String? = null,
var prevContent: String? = null,
var isUseless: Boolean = false,
@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,
@ -43,7 +41,13 @@ internal open class EventEntity(@Index var eventId: String = "",
var decryptionResultJson: String? = null,
var decryptionErrorCode: String? = null,
var decryptionErrorReason: String? = null,
var ageLocalTs: Long? = null
var ageLocalTs: Long? = null,
// 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,
var threadSummaryLatestMessage: TimelineEventEntity? = null
) : RealmObject() {
private var sendStateStr: String = SendState.UNKNOWN.name
@ -78,9 +82,6 @@ internal open class EventEntity(@Index var eventId: String = "",
?.canBeProcessed = true
}
/**
* Returns true if the current event is a thread root event
*/
fun isRootThread(): Boolean = isThread && rootThreadEventId == null
fun isThread(): Boolean = rootThreadEventId != null
}

View file

@ -33,11 +33,9 @@ internal fun ChunkEntity.Companion.find(realm: Realm, roomId: String, prevToken:
val query = where(realm, roomId)
if (prevToken != null) {
query.equalTo(ChunkEntityFields.PREV_TOKEN, prevToken)
query.isNull(ChunkEntityFields.ROOT_THREAD_EVENT_ID)
}
if (nextToken != null) {
query.equalTo(ChunkEntityFields.NEXT_TOKEN, nextToken)
query.isNull(ChunkEntityFields.ROOT_THREAD_EVENT_ID)
}
return query.findFirst()
}
@ -45,15 +43,15 @@ internal fun ChunkEntity.Companion.find(realm: Realm, roomId: String, prevToken:
internal fun ChunkEntity.Companion.findLastForwardChunkOfRoom(realm: Realm, roomId: String): ChunkEntity? {
return where(realm, roomId)
.equalTo(ChunkEntityFields.IS_LAST_FORWARD, true)
.isNull(ChunkEntityFields.ROOT_THREAD_EVENT_ID)
.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()
}
@ -72,16 +70,3 @@ internal fun ChunkEntity.Companion.create(
this.nextToken = nextToken
}
}
// Threads
internal fun ChunkEntity.Companion.findThreadChunkOfRoom(realm: Realm, roomId: String, rootThreadEventId: String): ChunkEntity? {
return where(realm, roomId)
.equalTo(ChunkEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId)
.findFirst()
}
internal fun ChunkEntity.Companion.findAllThreadChunkOfRoom(realm: Realm, roomId: String): RealmResults<ChunkEntity> {
return where(realm, roomId)
.isNotNull(ChunkEntityFields.ROOT_THREAD_EVENT_ID)
.findAll()
}

View file

@ -85,3 +85,8 @@ internal fun RealmList<EventEntity>.find(eventId: String): EventEntity? {
internal fun RealmList<EventEntity>.fastContains(eventId: String): Boolean {
return this.find(eventId) != null
}
internal fun EventEntity.Companion.whereRootThreadEventId(realm: Realm, rootThreadEventId: String): RealmQuery<EventEntity> {
return realm.where<EventEntity>()
.equalTo(EventEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId)
}

View file

@ -25,9 +25,11 @@ import io.realm.kotlin.where
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.room.timeline.TimelineEventFilters
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.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import timber.log.Timber
internal fun TimelineEventEntity.Companion.where(realm: Realm, roomId: String, eventId: String): RealmQuery<TimelineEventEntity> {
return realm.where<TimelineEventEntity>()
@ -59,6 +61,7 @@ internal fun TimelineEventEntity.Companion.latestEvent(realm: Realm,
filters: TimelineEventFilters = TimelineEventFilters()): TimelineEventEntity? {
val roomEntity = RoomEntity.where(realm, roomId).findFirst() ?: return null
val sendingTimelineEvents = roomEntity.sendingTimelineEvents.where().filterEvents(filters)
val liveEvents = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId)?.timelineEvents?.where()?.filterEvents(filters)
val query = if (includesSending && sendingTimelineEvents.findAll().isNotEmpty()) {
sendingTimelineEvents
@ -100,6 +103,7 @@ internal fun RealmQuery<TimelineEventEntity>.filterEvents(filters: TimelineEvent
if (filters.filterRedacted) {
not().like(TimelineEventEntityFields.ROOT.UNSIGNED_DATA, TimelineEventFilter.Unsigned.REDACTED)
}
return this
}

View file

@ -101,7 +101,7 @@ internal class DefaultTimeline(
private val builtEventsIdMap = Collections.synchronizedMap(HashMap<String, Int>())
private val backwardsState = AtomicReference(TimelineState())
private val forwardsState = AtomicReference(TimelineState())
private var isFromThreadTimeline = false
override val timelineID = UUID.randomUUID().toString()
override val isLive
@ -143,8 +143,9 @@ internal class DefaultTimeline(
}
}
override fun start() {
override fun start(rootThreadEventId: String?) {
if (isStarted.compareAndSet(false, true)) {
isFromThreadTimeline = rootThreadEventId != null
Timber.v("Start timeline for roomId: $roomId and eventId: $initialEventId")
timelineInput.listeners.add(this)
BACKGROUND_HANDLER.post {
@ -163,7 +164,13 @@ internal class DefaultTimeline(
postSnapshot()
}
timelineEvents = buildEventQuery(realm).sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findAll()
timelineEvents = rootThreadEventId?.let {
TimelineEventEntity
.whereRoomId(realm, roomId = roomId)
.equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, it)
.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findAll()
} ?: buildEventQuery(realm).sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING).findAll()
timelineEvents.addChangeListener(eventsChangeListener)
handleInitialLoad()
loadRoomMembersTask
@ -313,16 +320,18 @@ internal class DefaultTimeline(
val firstCacheEvent = results.firstOrNull()
val chunkEntity = getLiveChunk()
updateState(Timeline.Direction.FORWARDS) {
it.copy(
hasMoreInCache = !builtEventsIdMap.containsKey(firstCacheEvent?.eventId),
hasReachedEnd = chunkEntity?.isLastForward ?: false
hasMoreInCache = !builtEventsIdMap.containsKey(firstCacheEvent?.eventId), // what is in DB
hasReachedEnd = if (isFromThreadTimeline) true else chunkEntity?.isLastForward ?: false // if you neeed fetch more
)
}
updateState(Timeline.Direction.BACKWARDS) {
it.copy(
hasMoreInCache = !builtEventsIdMap.containsKey(lastCacheEvent?.eventId),
hasReachedEnd = chunkEntity?.isLastBackward ?: false || lastCacheEvent?.root?.type == EventType.STATE_ROOM_CREATE
hasReachedEnd = if (isFromThreadTimeline) true else chunkEntity?.isLastBackward ?: false || lastCacheEvent?.root?.type == EventType.STATE_ROOM_CREATE
)
}
}
@ -472,6 +481,7 @@ internal class DefaultTimeline(
* This has to be called on TimelineThread as it accesses realm live results
*/
private fun executePaginationTask(direction: Timeline.Direction, limit: Int) {
val currentChunk = getLiveChunk()
val token = if (direction == Timeline.Direction.BACKWARDS) currentChunk?.prevToken else currentChunk?.nextToken
if (token == null) {

View file

@ -21,6 +21,7 @@ import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.session.filter.FilterRepository
import org.matrix.android.sdk.internal.session.room.RoomAPI
import org.matrix.android.sdk.internal.task.Task
import timber.log.Timber
import javax.inject.Inject
internal interface PaginationTask : Task<PaginationTask.Params, TokenChunkEventPersistor.Result> {

View file

@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.session.room.timeline
import com.zhuinden.monarchy.Monarchy
import io.realm.Realm
import io.realm.kotlin.createObject
import org.matrix.android.sdk.BuildConfig
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.isThread
import org.matrix.android.sdk.api.session.events.model.toModel
@ -28,10 +29,10 @@ import org.matrix.android.sdk.internal.database.helper.addIfNecessary
import org.matrix.android.sdk.internal.database.helper.addStateEvent
import org.matrix.android.sdk.internal.database.helper.addTimelineEvent
import org.matrix.android.sdk.internal.database.helper.merge
import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded
import org.matrix.android.sdk.internal.database.mapper.toEntity
import org.matrix.android.sdk.internal.database.model.ChunkEntity
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventEntityFields
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.RoomSummaryEntity
@ -41,9 +42,9 @@ import org.matrix.android.sdk.internal.database.query.create
import org.matrix.android.sdk.internal.database.query.find
import org.matrix.android.sdk.internal.database.query.findAllIncludingEvents
import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom
import org.matrix.android.sdk.internal.database.query.findThreadChunkOfRoom
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.database.query.whereRootThreadEventId
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryEventsHelper
import org.matrix.android.sdk.internal.util.awaitTransaction
@ -160,6 +161,7 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri
handlePagination(realm, roomId, direction, receivedChunk, currentChunk)
}
}
return if (receivedChunk.events.isEmpty()) {
if (receivedChunk.hasMore()) {
Result.SHOULD_FETCH_MORE
@ -210,6 +212,8 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri
}
}
val eventIds = ArrayList<String>(eventList.size)
val optimizedThreadSummaryMap = hashMapOf<String,EventEntity>()
eventList.forEach { event ->
if (event.eventId == null || event.senderId == null) {
return@forEach
@ -226,16 +230,18 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri
roomMemberContentsByUser[event.stateKey] = contentToUse.toModel<RoomMemberContent>()
}
Timber.i("------> [TokenChunkEventPersistor] Add TimelineEvent to chunkEntity event[${event.eventId}] ${if (event.isThread()) "is Thread" else ""}")
currentChunk.addTimelineEvent(roomId, eventEntity, direction, 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
}
addTimelineEventToChunk(
realm = realm,
roomId = roomId,
eventEntity = eventEntity,
currentChunk = currentChunk,
direction = direction,
roomMemberContentsByUser = roomMemberContentsByUser)
}
// Find all the chunks which contain at least one event from the list of eventIds
val chunks = ChunkEntity.findAllIncludingEvents(realm, eventIds)
Timber.d("Found ${chunks.size} chunks containing at least one of the eventIds")
@ -254,49 +260,63 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri
val shouldUpdateSummary = roomSummaryEntity.latestPreviewableEvent == null ||
(chunksToDelete.isNotEmpty() && currentChunk.isLastForward && direction == PaginationDirection.FORWARDS)
if (shouldUpdateSummary) {
// TODO maybe add support to view latest thread message
roomSummaryEntity.latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId)
}
if (currentChunk.isValid) {
RoomEntity.where(realm, roomId).findFirst()?.addIfNecessary(currentChunk)
}
optimizedThreadSummaryMap.updateThreadSummaryIfNeeded()
}
/**
* Adds a timeline event to the correct chunk. If there is a thread detected will be added
* to a specific chunk
*/
private fun addTimelineEventToChunk(realm: Realm,
roomId: String,
eventEntity: EventEntity,
currentChunk: ChunkEntity,
direction: PaginationDirection,
roomMemberContentsByUser: Map<String, RoomMemberContent?>) {
val rootThreadEventId = eventEntity.rootThreadEventId
if (eventEntity.isThread && rootThreadEventId != null) {
val threadChunk = getOrCreateThreadChunk(realm, roomId, rootThreadEventId)
threadChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser)
markEventAsRootEvent(realm, rootThreadEventId)
if (threadChunk.isValid)
RoomEntity.where(realm, roomId).findFirst()?.addIfNecessary(threadChunk)
} else {
currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser)
}
}
// /**
// * Mark or update the thread root event accordingly. If the Threading is disabled
// * no action is done
// */
// private fun updateRootThreadEventIfNeeded(realm: Realm, eventEntity: EventEntity) {
//
// if (!BuildConfig.THREADING_ENABLED) return
//
// val rootThreadEventId = eventEntity.rootThreadEventId
//
// if (eventEntity.isThread && rootThreadEventId != null) {
// markEventAsRootEvent(realm, rootThreadEventId)
// } else {
// markAsRootEventIfNeeded(realm, eventEntity.eventId)
// }
// }
private fun markEventAsRootEvent(realm: Realm, rootThreadEventId: String) {
val rootThreadEvent = EventEntity
.where(realm, rootThreadEventId)
.equalTo(EventEntityFields.IS_THREAD, false).findFirst() ?: return
rootThreadEvent.isThread = true
}
// /**
// * Finds the event with rootThreadEventId and marks it as a root thread
// */
// private fun markEventAsRootEvent(realm: Realm, rootThreadEventId: String) {
// val rootThreadEvent = EventEntity
// .where(realm, rootThreadEventId)
// .equalTo(EventEntityFields.IS_THREAD, false).findFirst() ?: return
// rootThreadEvent.isThread = true
// }
//
// /**
// * Also check if there is at least one thread message for that rootThreadEventId,
// * that means it is a root thread so it should be updated accordingly
// */
// private fun markAsRootEventIfNeeded(realm: Realm, candidateIdRootThread: String) {
// EventEntity
// .whereRootThreadEventId(realm, candidateIdRootThread)
// .findFirst() ?: return
//
// markEventAsRootEvent(realm, candidateIdRootThread)
// }
/**
* Returns the chunk for the current room if exists, otherwise it creates a new ChunkEntity
*/
private fun getOrCreateThreadChunk(realm: Realm, roomId: String, rootThreadEventId: String): ChunkEntity {
return ChunkEntity.findThreadChunkOfRoom(realm, roomId, rootThreadEventId)
?: realm.createObject<ChunkEntity>().apply {
this.rootThreadEventId = rootThreadEventId
}
}
// /**
// * Returns the chunk for the current room if exists, otherwise it creates a new ChunkEntity
// */
// private fun getOrCreateThreadChunk(realm: Realm, roomId: String, rootThreadEventId: String): ChunkEntity {
// return ChunkEntity.findThreadChunkOfRoom(realm, roomId, rootThreadEventId)
// ?: realm.createObject<ChunkEntity>().apply {
// this.rootThreadEventId = rootThreadEventId
// }
// }
}

View file

@ -21,41 +21,33 @@ import io.realm.kotlin.createObject
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.isThread
import org.matrix.android.sdk.api.session.events.model.toModel
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.model.message.MessageRelationContent
import org.matrix.android.sdk.api.session.room.send.SendState
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
import org.matrix.android.sdk.api.session.sync.model.RoomsSyncResponse
import org.matrix.android.sdk.api.util.JsonDict
import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider
import org.matrix.android.sdk.internal.crypto.DefaultCryptoService
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.mapper.EventMapper
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
import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventEntityFields
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.query.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.database.query.find
import org.matrix.android.sdk.internal.database.query.findAllThreadChunkOfRoom
import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom
import org.matrix.android.sdk.internal.database.query.findThreadChunkOfRoom
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
@ -68,11 +60,8 @@ import org.matrix.android.sdk.internal.session.initsync.mapWithProgress
import org.matrix.android.sdk.internal.session.initsync.reportSubtask
import org.matrix.android.sdk.internal.session.room.membership.RoomChangeMembershipStateDataSource
import org.matrix.android.sdk.internal.session.room.membership.RoomMemberEventHandler
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.summary.RoomSummaryUpdater
import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection
import org.matrix.android.sdk.internal.session.room.timeline.TimelineEventDecryptor
import org.matrix.android.sdk.internal.session.room.timeline.TimelineInput
import org.matrix.android.sdk.internal.session.room.typing.TypingEventContent
import org.matrix.android.sdk.internal.session.sync.InitialSyncStrategy
@ -357,11 +346,14 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
syncLocalTimestampMillis: Long,
aggregator: SyncResponsePostTreatmentAggregator): ChunkEntity {
val lastChunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomEntity.roomId)
val chunkEntity = if (!isLimited && lastChunk != null) {
// There are no more events to fetch
lastChunk
} else {
realm.createObject<ChunkEntity>().apply { this.prevToken = prevToken }
}
// Only one chunk has isLastForward set to true
lastChunk?.isLastForward = false
chunkEntity.isLastForward = true
@ -369,21 +361,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
val eventIds = ArrayList<String>(eventList.size)
val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>()
/////////////////////
// There is only one chunk per room
val threadChunks = ChunkEntity.findAllThreadChunkOfRoom(realm, roomId)
val tc = threadChunks.joinToString { chunk ->
var output = "\n----------------\n------> [${chunk.timelineEvents.size}] rootThreadEventId = ${chunk.rootThreadEventId}" + "\n"
output += chunk.timelineEvents
.joinToString("") {
"------> " + "eventId:[${it?.eventId}] payload:[${getValueFromPayload(it.root?.let { root -> EventMapper.map(root).mxDecryptionResult }?.payload, "body")}]\n"
}
output
}
Timber.i("------> Chunks (${threadChunks.size})$tc")
/////////////////////
val optimizedThreadSummaryMap = hashMapOf<String, EventEntity>()
for (event in eventList) {
if (event.eventId == null || event.senderId == null || event.type == null) {
continue
@ -413,15 +391,14 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
rootStateEvent?.asDomain()?.getFixedRoomMemberContent()
}
Timber.i("------> [RoomSyncHandler] Add TimelineEvent to chunkEntity event[${event.eventId}] ${if (event.isThread()) "is Thread" else ""}")
addTimelineEventToChunk(
realm = realm,
roomId = roomId,
eventEntity = eventEntity,
chunkEntity = chunkEntity,
roomEntity = roomEntity,
roomMemberContentsByUser = roomMemberContentsByUser)
chunkEntity.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
}
// Give info to crypto module
cryptoService.onLiveEvent(roomEntity.roomId, event)
@ -447,56 +424,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
}
}
}
optimizedThreadSummaryMap.updateThreadSummaryIfNeeded()
// posting new events to timeline if any is registered
timelineInput.onNewTimelineEvents(roomId = roomId, eventIds = eventIds)
return chunkEntity
}
/**
* Adds a timeline event to the correct chunk. If there is a thread detected will be added
* to a specific chunk
*/
private fun addTimelineEventToChunk(realm: Realm,
roomId: String,
eventEntity: EventEntity,
chunkEntity: ChunkEntity,
roomEntity: RoomEntity,
roomMemberContentsByUser: Map<String, RoomMemberContent?>) {
val rootThreadEventId = eventEntity.rootThreadEventId
if (eventEntity.isThread && rootThreadEventId != null) {
val threadChunk = getOrCreateThreadChunk(realm, roomId, rootThreadEventId)
threadChunk.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser)
markEventAsRootEvent(realm, rootThreadEventId)
roomEntity.addIfNecessary(threadChunk)
} else {
chunkEntity.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser)
}
}
@Suppress("UNCHECKED_CAST")
private fun getValueFromPayload(payload: JsonDict?, key: String): String? {
val content = payload?.get("content") as? JsonDict
return content?.get(key) as? String
}
/**
* Returns the chunk for the current room if exists, otherwise it creates a new ChunkEntity
*/
private fun getOrCreateThreadChunk(realm: Realm, roomId: String, rootThreadEventId: String): ChunkEntity {
return ChunkEntity.findThreadChunkOfRoom(realm, roomId, rootThreadEventId)
?: realm.createObject<ChunkEntity>().apply {
this.rootThreadEventId = rootThreadEventId
}
}
private fun markEventAsRootEvent(realm: Realm, rootThreadEventId: String){
val rootThreadEvent = EventEntity
.where(realm, rootThreadEventId)
.equalTo(EventEntityFields.IS_THREAD, false).findFirst() ?: return
rootThreadEvent.isThread = true
}
private fun decryptIfNeeded(event: Event, roomId: String) {
try {
// Event from sync does not have roomId, so add it to the event first

View file

@ -159,6 +159,9 @@ android {
// This *must* only be set in trusted environments.
buildConfigField "Boolean", "handleCallAssertedIdentityEvents", "false"
// Indicates whether or not threading support is enabled
buildConfigField "Boolean", "THREADING_ENABLED", "${isThreadingEnabled}"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// Keep abiFilter for the universalApk

View file

@ -359,7 +359,6 @@ class RoomDetailFragment @Inject constructor(
} else {
setupToolbar(views.roomToolbar)
}
setupThreadIfNeeded()
setupRecyclerView()
setupComposer()
setupNotificationView()
@ -1194,12 +1193,6 @@ class RoomDetailFragment @Inject constructor(
// PRIVATE METHODS *****************************************************************************
private fun setupThreadIfNeeded(){
getRootThreadEventId()?.let{
textComposerViewModel.handle(TextComposerAction.EnterReplyInThreadTimeline(it))
}
}
private fun setupRecyclerView() {
timelineEventController.callback = this
timelineEventController.timeline = roomDetailViewModel.timeline
@ -1762,7 +1755,7 @@ class RoomDetailFragment @Inject constructor(
this.view?.hideKeyboard()
MessageActionsBottomSheet
.newInstance(roomId, informationData)
.newInstance(roomId, informationData, isThreadTimeLine())
.show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS")
return true

View file

@ -160,7 +160,7 @@ class RoomDetailViewModel @AssistedInject constructor(
}
init {
timeline.start()
timeline.start(initialState.rootThreadEventId)
timeline.addListener(this)
observeRoomSummary()
observeMembershipChanges()
@ -1094,6 +1094,7 @@ class RoomDetailViewModel @AssistedInject constructor(
}
override fun onTimelineUpdated(snapshot: List<TimelineEvent>) {
timelineEvents.tryEmit(snapshot)
// PreviewUrl

View file

@ -73,7 +73,8 @@ data class RoomDetailViewState(
roomId = args.roomId,
eventId = args.eventId,
// Also highlight the target event, if any
highlightedEventId = args.eventId
highlightedEventId = args.eventId,
rootThreadEventId = args.roomThreadDetailArgs?.eventId
)
fun isWebRTCCallOptionAvailable() = (asyncRoomSummary.invoke()?.joinedMembersCount ?: 0) <= 2

View file

@ -89,8 +89,6 @@ class TextComposerViewModel @AssistedInject constructor(
is TextComposerAction.UserIsTyping -> handleUserIsTyping(action)
is TextComposerAction.OnTextChanged -> handleOnTextChanged(action)
is TextComposerAction.OnVoiceRecordingStateChanged -> handleOnVoiceRecordingStateChanged(action)
is TextComposerAction.EnterReplyInThreadTimeline -> handleEnterReplyInThreadTimeline(action)
}
}
@ -98,10 +96,6 @@ class TextComposerViewModel @AssistedInject constructor(
copy(isVoiceRecording = action.isRecording)
}
private fun handleEnterReplyInThreadTimeline(action: TextComposerAction.EnterReplyInThreadTimeline) = setState {
copy(rootThreadEventId = action.rootThreadEventId)
}
private fun handleOnTextChanged(action: TextComposerAction.OnTextChanged) {
setState {
// Makes sure currentComposerText is upToDate when accessing further setState

View file

@ -53,7 +53,9 @@ data class TextComposerViewState(
val isComposerVisible: Boolean
get() = canSendMessage && !isVoiceRecording
constructor(args: RoomDetailArgs) : this(roomId = args.roomId)
constructor(args: RoomDetailArgs) : this(
roomId = args.roomId,
rootThreadEventId = args.roomThreadDetailArgs?.eventId)
fun isInThreadTimeline(): Boolean = rootThreadEventId != null
}

View file

@ -93,14 +93,16 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
val unreadState: UnreadState = UnreadState.Unknown,
val highlightedEventId: String? = null,
val jitsiState: JitsiState = JitsiState(),
val roomSummary: RoomSummary? = null
val roomSummary: RoomSummary? = null,
val rootThreadEventId: String? = null
) {
constructor(state: RoomDetailViewState) : this(
unreadState = state.unreadState,
highlightedEventId = state.highlightedEventId,
jitsiState = state.jitsiState,
roomSummary = state.asyncRoomSummary()
roomSummary = state.asyncRoomSummary(),
rootThreadEventId = state.rootThreadEventId
)
}
@ -191,7 +193,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
// it's sent by the same user so we are sure we have up to date information.
val invalidatedSenderId: String? = currentSnapshot.getOrNull(position)?.senderInfo?.userId
val prevDisplayableEventIndex = currentSnapshot.subList(0, position).indexOfLast {
timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId)
timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId, partialState.rootThreadEventId )
}
if (prevDisplayableEventIndex != -1 && currentSnapshot[prevDisplayableEventIndex].senderInfo.userId == invalidatedSenderId) {
modelCache[prevDisplayableEventIndex] = null
@ -319,6 +321,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
}
private fun submitSnapshot(newSnapshot: List<TimelineEvent>) {
// Update is triggered on any DB change
backgroundHandler.post {
inSubmitList = true
val diffCallback = TimelineEventDiffUtilCallback(currentSnapshot, newSnapshot)
@ -367,7 +370,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
val nextEvent = currentSnapshot.nextOrNull(position)
val prevEvent = currentSnapshot.prevOrNull(position)
val nextDisplayableEvent = currentSnapshot.subList(position + 1, currentSnapshot.size).firstOrNull {
timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId)
timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId, partialState.rootThreadEventId)
}
// Should be build if not cached or if model should be refreshed
if (modelCache[position] == null || modelCache[position]?.isCacheable == false) {
@ -449,7 +452,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
return null
}
// If the event is not shown, we go to the next one
if (!timelineEventVisibilityHelper.shouldShowEvent(event, partialState.highlightedEventId)) {
if (!timelineEventVisibilityHelper.shouldShowEvent(event, partialState.highlightedEventId, partialState.rootThreadEventId)) {
continue
}
// If the event is sent by us, we update the holder with the eventId and stop the search
@ -471,7 +474,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
val currentReadReceipts = ArrayList(event.readReceipts).filter {
it.user.userId != session.myUserId
}
if (timelineEventVisibilityHelper.shouldShowEvent(event, partialState.highlightedEventId)) {
if (timelineEventVisibilityHelper.shouldShowEvent(event, partialState.highlightedEventId, partialState.rootThreadEventId)) {
lastShownEventId = event.eventId
}
if (lastShownEventId == null) {

View file

@ -49,10 +49,15 @@ data class MessageActionState(
// For actions
val actions: List<EventSharedAction> = emptyList(),
val expendedReportContentMenu: Boolean = false,
val actionPermissions: ActionPermissions = ActionPermissions()
val actionPermissions: ActionPermissions = ActionPermissions(),
val isFromThreadTimeline: Boolean = false
) : MavericksState {
constructor(args: TimelineEventFragmentArgs) : this(roomId = args.roomId, eventId = args.eventId, informationData = args.informationData)
constructor(args: TimelineEventFragmentArgs) : this(
roomId = args.roomId,
eventId = args.eventId,
informationData = args.informationData,
isFromThreadTimeline = args.isFromThreadTimeline)
fun senderName(): String = informationData.memberName?.toString() ?: ""

View file

@ -97,13 +97,14 @@ class MessageActionsBottomSheet :
}
companion object {
fun newInstance(roomId: String, informationData: MessageInformationData): MessageActionsBottomSheet {
fun newInstance(roomId: String, informationData: MessageInformationData, isFromThreadTimeline: Boolean): MessageActionsBottomSheet {
return MessageActionsBottomSheet().apply {
setArguments(
TimelineEventFragmentArgs(
informationData.eventId,
roomId,
informationData
informationData,
isFromThreadTimeline
)
)
}

View file

@ -22,6 +22,7 @@ import dagger.Lazy
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.BuildConfig
import im.vector.app.R
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.extensions.canReact
@ -326,7 +327,6 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
add(EventSharedAction.Reply(eventId))
}
// *** Testing Threads ****
if (canReplyInThread(timelineEvent, messageContent, actionPermissions)) {
add(EventSharedAction.ReplyInThread(eventId))
}
@ -417,18 +417,22 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
}
}
private fun canReplyInThread(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean {
private fun canReplyInThread(event: TimelineEvent,
messageContent: MessageContent?,
actionPermissions: ActionPermissions): Boolean {
// Only event of type EventType.MESSAGE are supported for the moment
if (!BuildConfig.THREADING_ENABLED) return false
if (initialState.isFromThreadTimeline) return false
if (event.root.getClearType() != EventType.MESSAGE) return false
if (!actionPermissions.canSendMessage) return false
return when (messageContent?.msgType) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_FILE -> true
MessageType.MSGTYPE_TEXT -> true
// MessageType.MSGTYPE_NOTICE,
// MessageType.MSGTYPE_EMOTE,
// MessageType.MSGTYPE_IMAGE,
// MessageType.MSGTYPE_VIDEO,
// MessageType.MSGTYPE_AUDIO,
// MessageType.MSGTYPE_FILE -> true
else -> false
}
}

View file

@ -24,5 +24,6 @@ import kotlinx.parcelize.Parcelize
data class TimelineEventFragmentArgs(
val eventId: String,
val roomId: String,
val informationData: MessageInformationData
val informationData: MessageInformationData,
val isFromThreadTimeline: Boolean = false
) : Parcelable

View file

@ -83,7 +83,7 @@ class MergedHeaderItemFactory @Inject constructor(private val activeSessionHolde
eventIdToHighlight: String?,
requestModelBuild: () -> Unit,
callback: TimelineEventController.Callback?): MergedMembershipEventsItem_? {
val mergedEvents = timelineEventVisibilityHelper.prevSameTypeEvents(items, currentPosition, 2, eventIdToHighlight)
val mergedEvents = timelineEventVisibilityHelper.prevSameTypeEvents(items, currentPosition, 2, eventIdToHighlight, partialState.rootThreadEventId)
return if (mergedEvents.isEmpty()) {
null
} else {

View file

@ -149,7 +149,7 @@ class MessageItemFactory @Inject constructor(
// This is an edit event, we should display it when debugging as a notice event
return noticeItemFactory.create(params)
}
val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback, event.root.isRootThread)
val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback, event.root.threadDetails)
// val all = event.root.toContent()
// val ev = all.toModel<Event>()

View file

@ -42,8 +42,8 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
fun create(params: TimelineItemFactoryParams): VectorEpoxyModel<*> {
val event = params.event
val computedModel = try {
if (!timelineEventVisibilityHelper.shouldShowEvent(event, params.highlightedEventId)) {
return buildEmptyItem(event, params.prevEvent, params.highlightedEventId)
if (!timelineEventVisibilityHelper.shouldShowEvent(event, params.highlightedEventId, params.rootThreadEventId)) {
return buildEmptyItem(event, params.prevEvent, params.highlightedEventId, params.rootThreadEventId)
}
when (event.root.getClearType()) {
// Message itemsX
@ -109,11 +109,11 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
Timber.e(throwable, "failed to create message item")
defaultItemFactory.create(params, throwable)
}
return computedModel ?: buildEmptyItem(event, params.prevEvent, params.highlightedEventId)
return computedModel ?: buildEmptyItem(event, params.prevEvent, params.highlightedEventId, params.rootThreadEventId)
}
private fun buildEmptyItem(timelineEvent: TimelineEvent, prevEvent: TimelineEvent?, highlightedEventId: String?): TimelineEmptyItem {
val isNotBlank = prevEvent == null || timelineEventVisibilityHelper.shouldShowEvent(prevEvent, highlightedEventId)
private fun buildEmptyItem(timelineEvent: TimelineEvent, prevEvent: TimelineEvent?, highlightedEventId: String?, rootThreadEventId: String?): TimelineEmptyItem {
val isNotBlank = prevEvent == null || timelineEventVisibilityHelper.shouldShowEvent(prevEvent, highlightedEventId, rootThreadEventId)
return TimelineEmptyItem_()
.id(timelineEvent.localId)
.eventId(timelineEvent.eventId)

View file

@ -34,5 +34,8 @@ data class TimelineItemFactoryParams(
val highlightedEventId: String?
get() = partialState.highlightedEventId
val rootThreadEventId: String?
get() = partialState.rootThreadEventId
val isHighlighted = highlightedEventId == event.eventId
}

View file

@ -21,6 +21,7 @@ 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.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import org.matrix.android.sdk.api.session.threads.ThreadDetails
import javax.inject.Inject
class MessageItemAttributesFactory @Inject constructor(
@ -32,7 +33,7 @@ class MessageItemAttributesFactory @Inject constructor(
fun create(messageContent: Any?,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?,
isRootThread: Boolean = false): AbsMessageItem.Attributes {
threadDetails: ThreadDetails? = null): AbsMessageItem.Attributes {
return AbsMessageItem.Attributes(
avatarSize = avatarSizeProvider.avatarSize,
informationData = informationData,
@ -51,7 +52,7 @@ class MessageItemAttributesFactory @Inject constructor(
avatarCallback = callback,
readReceiptsCallback = callback,
emojiTypeFace = emojiCompatFontProvider.typeface,
isRootThread = isRootThread
threadDetails = threadDetails
)
}
}

View file

@ -18,9 +18,12 @@ package im.vector.app.features.home.room.detail.timeline.helper
import im.vector.app.core.extensions.localDateTime
import im.vector.app.core.resources.UserPreferencesProvider
import org.matrix.android.sdk.BuildConfig
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.events.model.getRelationContent
import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId
import org.matrix.android.sdk.api.session.events.model.isThread
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
@ -37,7 +40,7 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
*
* @return a list of timeline events which have sequentially the same type following the next direction.
*/
fun nextSameTypeEvents(timelineEvents: List<TimelineEvent>, index: Int, minSize: Int, eventIdToHighlight: String?): List<TimelineEvent> {
private fun nextSameTypeEvents(timelineEvents: List<TimelineEvent>, index: Int, minSize: Int, eventIdToHighlight: String?, rootThreadEventId: String?): List<TimelineEvent> {
if (index >= timelineEvents.size - 1) {
return emptyList()
}
@ -59,7 +62,7 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
} else {
nextSameDayEvents.subList(0, indexOfFirstDifferentEventType)
}
val filteredSameTypeEvents = sameTypeEvents.filter { shouldShowEvent(it, eventIdToHighlight) }
val filteredSameTypeEvents = sameTypeEvents.filter { shouldShowEvent(it, eventIdToHighlight, rootThreadEventId) }
if (filteredSameTypeEvents.size < minSize) {
return emptyList()
}
@ -74,12 +77,12 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
*
* @return a list of timeline events which have sequentially the same type following the prev direction.
*/
fun prevSameTypeEvents(timelineEvents: List<TimelineEvent>, index: Int, minSize: Int, eventIdToHighlight: String?): List<TimelineEvent> {
fun prevSameTypeEvents(timelineEvents: List<TimelineEvent>, index: Int, minSize: Int, eventIdToHighlight: String?, rootThreadEventId: String?): List<TimelineEvent> {
val prevSub = timelineEvents.subList(0, index + 1)
return prevSub
.reversed()
.let {
nextSameTypeEvents(it, 0, minSize, eventIdToHighlight)
nextSameTypeEvents(it, 0, minSize, eventIdToHighlight, rootThreadEventId)
}
}
@ -88,7 +91,7 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
* @param highlightedEventId can be checked to force visibility to true
* @return true if the event should be shown in the timeline.
*/
fun shouldShowEvent(timelineEvent: TimelineEvent, highlightedEventId: String?): Boolean {
fun shouldShowEvent(timelineEvent: TimelineEvent, highlightedEventId: String?, rootThreadEventId: String?): Boolean {
// If show hidden events is true we should always display something
if (userPreferencesProvider.shouldShowHiddenEvents()) {
return true
@ -100,15 +103,16 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
if (!timelineEvent.isDisplayable()) {
return false
}
// Check for special case where we should hide the event, like redacted, relation, memberships... according to user preferences.
return !timelineEvent.shouldBeHidden()
return !timelineEvent.shouldBeHidden(rootThreadEventId)
}
private fun TimelineEvent.isDisplayable(): Boolean {
return TimelineDisplayableEvents.DISPLAYABLE_TYPES.contains(root.getClearType())
}
private fun TimelineEvent.shouldBeHidden(): Boolean {
private fun TimelineEvent.shouldBeHidden(rootThreadEventId: String?): Boolean {
if (root.isRedacted() && !userPreferencesProvider.shouldShowRedactedMessages()) {
return true
}
@ -120,6 +124,11 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
if ((diff.isJoin || diff.isPart) && !userPreferencesProvider.shouldShowJoinLeaves()) return true
if ((diff.isAvatarChange || diff.isDisplaynameChange) && !userPreferencesProvider.shouldShowAvatarDisplayNameChanges()) return true
}
if(BuildConfig.THREADING_ENABLED && rootThreadEventId == null && root.isThread() && root.getRootThreadEventId() != null){
return true
}
return false
}

View file

@ -18,10 +18,12 @@ package im.vector.app.features.home.room.detail.timeline.item
import android.graphics.Typeface
import android.view.View
import android.view.ViewStub
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.annotation.IdRes
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
@ -32,6 +34,9 @@ import im.vector.app.core.ui.views.SendStateImageView
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 org.matrix.android.sdk.api.session.threads.ThreadDetails
import org.matrix.android.sdk.api.util.MatrixItem
import timber.log.Timber
/**
* Base timeline item that adds an optional information bar with the sender avatar, name, time, send state
@ -98,9 +103,20 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
// Render send state indicator
holder.sendStateImageView.render(attributes.informationData.sendStateDecoration)
holder.eventSendingIndicator.isVisible = attributes.informationData.sendStateDecoration == SendStateDecoration.SENDING_MEDIA
holder.isThread.isVisible = attributes.isRootThread
// Threads
attributes.threadDetails?.let { threadDetails ->
threadDetails.isRootThread
holder.threadSummaryConstraintLayout.isVisible = threadDetails.isRootThread
holder.threadSummaryCounterTextView.text = threadDetails.numberOfThreads.toString()
holder.threadSummaryInfoTextView.text = threadDetails.threadSummaryLatestTextMessage
threadDetails.threadSummarySenderInfo?.let { senderInfo ->
attributes.avatarRenderer.render(MatrixItem.UserItem(senderInfo.userId, senderInfo.displayName, senderInfo.avatarUrl), holder.threadSummaryAvatarImageView)
}
}
}
override fun unbind(holder: H) {
attributes.avatarRenderer.clear(holder.avatarImageView)
holder.avatarImageView.setOnClickListener(null)
@ -118,7 +134,11 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
val timeView by bind<TextView>(R.id.messageTimeView)
val sendStateImageView by bind<SendStateImageView>(R.id.messageSendStateImageView)
val eventSendingIndicator by bind<ProgressBar>(R.id.eventSendingIndicator)
val isThread by bind<View>(R.id.messageIsThread)
val threadSummaryConstraintLayout by bind<ConstraintLayout>(R.id.messageThreadSummaryConstraintLayout)
val threadSummaryCounterTextView by bind<TextView>(R.id.messageThreadSummaryCounterTextView)
val threadSummaryImageView by bind<ImageView>(R.id.messageThreadSummaryImageView)
val threadSummaryAvatarImageView by bind<ImageView>(R.id.messageThreadSummaryAvatarImageView)
val threadSummaryInfoTextView by bind<TextView>(R.id.messageThreadSummaryInfoTextView)
}
/**
@ -136,7 +156,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
val avatarCallback: TimelineEventController.AvatarCallback? = null,
override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null,
val emojiTypeFace: Typeface? = null,
val isRootThread: Boolean = false
val threadDetails: ThreadDetails? = null
) : AbsBaseMessageItem.Attributes {
// Have to override as it's used to diff epoxy items
@ -148,6 +168,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
if (avatarSize != other.avatarSize) return false
if (informationData != other.informationData) return false
if (threadDetails != other.threadDetails) return false
return true
}
@ -155,6 +176,8 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
override fun hashCode(): Int {
var result = avatarSize
result = 31 * result + informationData.hashCode()
result = 31 * result + threadDetails.hashCode()
return result
}
}

View file

@ -110,7 +110,7 @@ class MergedTimelines(
secondaryTimeline.removeAllListeners()
}
override fun start() {
override fun start(rootThreadEventId: String?) {
mainTimeline.start()
secondaryTimeline.start()
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,11 @@
<vector android:height="16dp" android:viewportHeight="18"
android:viewportWidth="18" android:width="16dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#737D8C" android:pathData="M14.9995,1H2.9995C1.8949,1 0.9995,1.8954 0.9995,3V14V17L4.4662,14.4C4.8124,14.1404 5.2334,14 5.6662,14H6.9995H14.9995C16.1041,14 16.9995,13.1046 16.9995,12V8V2.9994C16.9995,1.8948 16.1041,1 14.9995,1Z"/>
<path android:fillColor="#737D8C" android:pathData="M4.4662,14.4L4.0162,13.8H4.0162L4.4662,14.4ZM0.9995,17H0.2495C0.2495,17.2841 0.41,17.5438 0.6641,17.6708C0.9182,17.7979 1.2222,17.7704 1.4495,17.6L0.9995,17ZM2.9995,1.75H14.9995V0.25H2.9995V1.75ZM1.7495,14V3H0.2495V14H1.7495ZM16.2495,2.9994V8H17.7495V2.9994H16.2495ZM4.0162,13.8L0.5495,16.4L1.4495,17.6L4.9162,15L4.0162,13.8ZM1.7495,17V14H0.2495V17H1.7495ZM5.6662,14.75H6.9995V13.25H5.6662V14.75ZM6.9995,14.75H14.9995V13.25H6.9995V14.75ZM17.7495,12V8H16.2495V12H17.7495ZM14.9995,14.75C16.5183,14.75 17.7495,13.5188 17.7495,12H16.2495C16.2495,12.6904 15.6899,13.25 14.9995,13.25V14.75ZM4.9162,15C5.1325,14.8377 5.3957,14.75 5.6662,14.75V13.25C5.0712,13.25 4.4922,13.443 4.0162,13.8L4.9162,15ZM14.9995,1.75C15.6902,1.75 16.2495,2.3093 16.2495,2.9994H17.7495C17.7495,1.4803 16.518,0.25 14.9995,0.25V1.75ZM2.9995,0.25C1.4807,0.25 0.2495,1.4812 0.2495,3H1.7495C1.7495,2.3096 2.3092,1.75 2.9995,1.75V0.25Z"/>
<path android:fillColor="#00000000"
android:pathData="M4.9995,6C4.9995,6 9.0943,6 12.9995,6"
android:strokeColor="#F4F6FA" android:strokeLineCap="round" android:strokeWidth="1.5"/>
<path android:fillColor="#00000000"
android:pathData="M4.9995,9H8.9995"
android:strokeColor="#F4F6FA" android:strokeLineCap="round" android:strokeWidth="1.5"/>
</vector>

View file

@ -33,8 +33,8 @@
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="4dp"
android:layout_toStartOf="@+id/messageTimeView"
android:layout_toEndOf="@+id/messageStartGuideline"
android:layout_toStartOf="@id/messageTimeView"
android:layout_toEndOf="@id/messageStartGuideline"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?vctr_content_primary"
@ -200,17 +200,7 @@
</com.google.android.flexbox.FlexboxLayout>
</LinearLayout>
<View
android:id="@+id/messageIsThread"
android:layout_width="wrap_content"
android:background="#2653AE"
android:layout_height="2dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="20dp"
android:layout_marginBottom="4dp"
android:layout_below="@id/informationBottom"
android:layout_toStartOf="@id/messageSendStateImageView"
android:layout_toEndOf="@id/messageStartGuideline"
android:contentDescription="@string/room_threads_filter" />
<include
layout="@layout/view_thread_room_summary" />
</RelativeLayout>

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/messageThreadSummaryConstraintLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/informationBottom"
android:layout_marginEnd="32dp"
android:layout_marginBottom="4dp"
android:layout_toEndOf="@id/messageStartGuideline"
android:background="@drawable/rounded_rect_shape_8"
android:contentDescription="@string/room_threads_filter"
android:maxWidth="496dp"
android:minWidth="144dp"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:id="@+id/messageThreadSummaryImageView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="13dp"
android:layout_marginTop="2dp"
android:contentDescription="@string/room_threads_filter"
android:src="@drawable/ic_thread_summary" />
<TextView
android:id="@+id/messageThreadSummaryCounterTextView"
style="@style/Widget.Vector.TextView.Caption"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/messageThreadSummaryImageView"
app:layout_constraintTop_toTopOf="parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:textColor="?vctr_content_secondary"
tools:text="187" />
<ImageView
android:id="@+id/messageThreadSummaryAvatarImageView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/messageThreadSummaryCounterTextView"
app:layout_constraintTop_toTopOf="parent"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="13dp"
android:contentDescription="@string/avatar"
tools:src="@sample/user_round_avatars" />
<TextView
android:id="@+id/messageThreadSummaryInfoTextView"
style="@style/Widget.Vector.TextView.Caption"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/messageThreadSummaryAvatarImageView"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_default="wrap"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="13dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?vctr_content_secondary"
tools:text="Hello There, whats up! Its a large centence" />
</androidx.constraintlayout.widget.ConstraintLayout>