Reply In Thread, create a new thread timeline

This commit is contained in:
ariskotsomitopoulos 2021-11-08 20:46:37 +02:00
parent cb0fefa74d
commit ecc9b59ad1
26 changed files with 340 additions and 154 deletions

View file

@ -96,6 +96,9 @@ data class Event(
@Transient
var sendStateDetails: String? = null
@Transient
var isRootThread: Boolean = false
fun sendStateError(): MatrixError? {
return sendStateDetails?.let {
val matrixErrorAdapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java)
@ -299,12 +302,22 @@ fun Event.getRelationContent(): RelationDefaultContent? {
}
}
/**
* Returns the relation content for a specific type or null otherwise
*/
fun Event.getRelationContentForType(type: String): RelationDefaultContent? =
getRelationContent()?.takeIf { it.type == type }
fun Event.isReply(): Boolean {
return getRelationContent()?.inReplyTo?.eventId != null
}
fun Event.isThread(): Boolean = getRelationContentForType(RelationType.THREAD)?.eventId != null
fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.THREAD)?.eventId
fun Event.isEdition(): Boolean {
return getRelationContent()?.takeIf { it.type == RelationType.REPLACE }?.eventId != null
return getRelationContentForType(RelationType.REPLACE)?.eventId != null
}
fun Event.getPresenceContent(): PresenceContent? {

View file

@ -131,11 +131,11 @@ interface RelationService {
* Creates a thread reply for an existing timeline event
* The replyInThreadText can be a Spannable and contains special spans (MatrixItemSpan) that will be translated
* by the sdk into pills.
* @param eventToReplyInThread the event referenced by the thread reply
* @param rootThreadEventId the root thread eventId
* @param replyInThreadText the reply text
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
*/
fun replyInThread(eventToReplyInThread: TimelineEvent,
fun replyInThread(rootThreadEventId: String,
replyInThreadText: CharSequence,
autoMarkdown: Boolean = false): Cancelable?
}

View file

@ -1,28 +0,0 @@
/*
* 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.room.model.relation.threads
interface ThreadContent {
companion object {
const val MSG_TYPE_JSON_KEY = "msgtype"
}
val msgType: String
val body: String
val relatesTo: ThreadRelatesTo?
}

View file

@ -1,31 +0,0 @@
/*
* Copyright (c) 2021 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 org.matrix.android.sdk.api.session.room.model.relation.threads
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.room.model.relation.RelationContent
import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent
@JsonClass(generateAdapter = true)
data class ThreadRelatesTo(
@Json(name = "rel_type") override val type: String? = RelationType.THREAD,
@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
) : RelationContent

View file

@ -1,28 +0,0 @@
/*
* 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.room.model.relation.threads
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
@JsonClass(generateAdapter = true)
data class ThreadTextContent(
@Json(name = MessageContent.MSG_TYPE_JSON_KEY) override val msgType: String,
@Json(name = "body") override val body: String,
@Json(name = "m.relates_to") override val relatesTo: ThreadRelatesTo? = null,
) : ThreadContent

View file

@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomJoinRulesContent
import org.matrix.android.sdk.api.session.room.model.VersioningState
import org.matrix.android.sdk.api.session.room.model.create.RoomCreateContent
import org.matrix.android.sdk.api.session.room.model.tag.RoomTag
import org.matrix.android.sdk.internal.database.model.ChunkEntityFields
import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields
import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntityFields
import org.matrix.android.sdk.internal.database.model.EditionOfEventFields
@ -49,7 +50,7 @@ import timber.log.Timber
internal object RealmSessionStoreMigration : RealmMigration {
const val SESSION_STORE_SCHEMA_VERSION = 18L
const val SESSION_STORE_SCHEMA_VERSION = 19L
override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) {
Timber.v("Migrating Realm Session from $oldVersion to $newVersion")
@ -72,6 +73,7 @@ internal object RealmSessionStoreMigration : RealmMigration {
if (oldVersion <= 15) migrateTo16(realm)
if (oldVersion <= 16) migrateTo17(realm)
if (oldVersion <= 17) migrateTo18(realm)
if (oldVersion <= 18) migrateTo19(realm)
}
private fun migrateTo1(realm: DynamicRealm) {
@ -364,4 +366,13 @@ internal object RealmSessionStoreMigration : RealmMigration {
realm.schema.get("RoomMemberSummaryEntity")
?.addRealmObjectField(RoomMemberSummaryEntityFields.USER_PRESENCE_ENTITY.`$`, userPresenceEntity)
}
private fun migrateTo19(realm: DynamicRealm) {
Timber.d("Step 18 -> 19")
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)
}
}

View file

@ -21,6 +21,8 @@ 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.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.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.database.model.EventEntity
@ -39,6 +41,8 @@ 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
@ -93,6 +97,7 @@ internal object EventMapper {
MXCryptoError.ErrorType.valueOf(errorCode)
}
it.mCryptoErrorReason = eventEntity.decryptionErrorReason
it.isRootThread = eventEntity.isRootThread()
}
}
}

View file

@ -27,6 +27,7 @@ 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,
@ -44,6 +45,8 @@ internal open class ChunkEntity(@Index var prevToken: String? = null,
val room: RealmResults<RoomEntity>? = null
companion object
fun isThreadChunk() = rootThreadEventId != null
}
internal fun ChunkEntity.deleteOnCascade(deleteStateEvents: Boolean, canDeleteRoot: Boolean) {

View file

@ -27,6 +27,8 @@ 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,
@ -75,4 +77,10 @@ internal open class EventEntity(@Index var eventId: String = "",
.findFirst()
?.canBeProcessed = true
}
/**
* Returns true if the current event is a thread root event
*/
fun isRootThread(): Boolean = isThread && rootThreadEventId == null
}

View file

@ -33,9 +33,11 @@ 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()
}
@ -43,12 +45,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()
}
@ -56,6 +61,7 @@ internal fun ChunkEntity.Companion.findIncludingEvent(realm: Realm, eventId: Str
return findAllIncludingEvents(realm, listOf(eventId)).firstOrNull()
}
internal fun ChunkEntity.Companion.create(
realm: Realm,
prevToken: String?,
@ -66,3 +72,16 @@ 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

@ -159,11 +159,15 @@ internal class DefaultRelationService @AssistedInject constructor(
}
}
override fun replyInThread(eventToReplyInThread: TimelineEvent, replyInThreadText: CharSequence, autoMarkdown: Boolean): Cancelable? {
val event = eventFactory.createThreadTextEvent(eventToReplyInThread, TextContent(replyInThreadText.toString()))
.also {
saveLocalEcho(it)
}
override fun replyInThread(rootThreadEventId: String, replyInThreadText: CharSequence, autoMarkdown: Boolean): Cancelable {
val event = eventFactory.createThreadTextEvent(
rootThreadEventId = rootThreadEventId,
roomId = roomId,
text = replyInThreadText.toString(),
autoMarkdown = autoMarkdown)
// .also {
// saveLocalEcho(it)
// }
return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId))
}

View file

@ -51,8 +51,6 @@ import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent
import org.matrix.android.sdk.api.session.room.model.relation.ReactionInfo
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent
import org.matrix.android.sdk.api.session.room.model.relation.threads.ThreadTextContent
import org.matrix.android.sdk.api.session.room.model.relation.threads.ThreadRelatesTo
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
import org.matrix.android.sdk.api.session.room.timeline.isReply
@ -60,7 +58,6 @@ import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.content.ThumbnailExtractor
import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory
import org.matrix.android.sdk.internal.session.room.send.pills.TextPillsUtils
import timber.log.Timber
import javax.inject.Inject
/**
@ -346,11 +343,13 @@ internal class LocalEchoEventFactory @Inject constructor(
/**
* Creates a thread event related to the already existing event
*/
fun createThreadTextEvent(eventToReplyInThread: TimelineEvent, textContent: TextContent): Event =
fun createThreadTextEvent(rootThreadEventId: String, roomId:String, text: String, autoMarkdown: Boolean): Event =
createEvent(
eventToReplyInThread.roomId,
roomId,
EventType.MESSAGE,
textContent.toThreadTextContent(eventToReplyInThread).toContent())
createTextContent(text, autoMarkdown)
.toThreadTextContent(rootThreadEventId)
.toContent())
private fun dummyOriginServerTs(): Long {
return System.currentTimeMillis()

View file

@ -16,13 +16,11 @@
package org.matrix.android.sdk.internal.session.room.send
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
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.threads.ThreadTextContent
import org.matrix.android.sdk.api.session.room.model.relation.threads.ThreadRelatesTo
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromHtmlReply
import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromReply
@ -45,11 +43,13 @@ fun TextContent.toMessageTextContent(msgType: String = MessageType.MSGTYPE_TEXT)
)
}
fun TextContent.toThreadTextContent(eventToReplyInThread: TimelineEvent, msgType: String = MessageType.MSGTYPE_TEXT): ThreadTextContent {
return ThreadTextContent(
fun TextContent.toThreadTextContent(rootThreadEventId: String, msgType: String = MessageType.MSGTYPE_TEXT): MessageTextContent {
return MessageTextContent(
msgType = msgType,
format = MessageFormat.FORMAT_MATRIX_HTML.takeIf { formattedText != null },
body = text,
relatesTo = ThreadRelatesTo(eventId = eventToReplyInThread.eventId)
relatesTo = RelationDefaultContent(RelationType.THREAD, rootThreadEventId),
formattedBody = formattedText
)
}

View file

@ -18,7 +18,9 @@ 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.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.room.model.RoomMemberContent
import org.matrix.android.sdk.api.session.room.send.SendState
@ -28,6 +30,8 @@ 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.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
@ -37,6 +41,7 @@ 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.di.SessionDatabase
@ -221,7 +226,15 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri
roomMemberContentsByUser[event.stateKey] = contentToUse.toModel<RoomMemberContent>()
}
currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser)
Timber.i("------> [TokenChunkEventPersistor] Add TimelineEvent to chunkEntity event[${event.eventId}] ${if (event.isThread()) "is Thread" else ""}")
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)
@ -247,4 +260,43 @@ internal class TokenChunkEventPersistor @Inject constructor(@SessionDatabase pri
RoomEntity.where(realm, roomId).findFirst()?.addIfNecessary(currentChunk)
}
}
/**
* 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)
}
}
private fun markEventAsRootEvent(realm: Realm, rootThreadEventId: String) {
val rootThreadEvent = EventEntity
.where(realm, rootThreadEventId)
.equalTo(EventEntityFields.IS_THREAD, false).findFirst() ?: return
rootThreadEvent.isThread = true
}
/**
* 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,31 +21,41 @@ 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.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
@ -58,8 +68,11 @@ 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
@ -356,6 +369,21 @@ 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")
/////////////////////
for (event in eventList) {
if (event.eventId == null || event.senderId == null || event.type == null) {
continue
@ -385,7 +413,16 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
rootStateEvent?.asDomain()?.getFixedRoomMemberContent()
}
chunkEntity.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser)
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)
// Give info to crypto module
cryptoService.onLiveEvent(roomEntity.roomId, event)
@ -412,9 +449,54 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
}
// 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

@ -49,6 +49,7 @@ import androidx.core.text.toSpannable
import androidx.core.util.Pair
import androidx.core.view.ViewCompat
import androidx.core.view.forEach
import androidx.core.view.isGone
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.fragment.app.setFragmentResultListener
@ -228,7 +229,8 @@ data class RoomDetailArgs(
val roomId: String,
val eventId: String? = null,
val sharedData: SharedData? = null,
val openShareSpaceForId: String? = null
val openShareSpaceForId: String? = null,
val roomThreadDetailArgs: RoomThreadDetailArgs? = null
) : Parcelable
class RoomDetailFragment @Inject constructor(
@ -352,7 +354,12 @@ class RoomDetailFragment @Inject constructor(
)
keyboardStateUtils = KeyboardStateUtils(requireActivity())
lazyLoadedViews.bind(views)
if (isThreadTimeLine()) {
views.roomToolbar.isGone = true
} else {
setupToolbar(views.roomToolbar)
}
setupThreadIfNeeded()
setupRecyclerView()
setupComposer()
setupNotificationView()
@ -902,6 +909,7 @@ class RoomDetailFragment @Inject constructor(
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
if (isThreadTimeLine()) return
// We use a custom layout for this menu item, so we need to set a ClickListener
menu.findItem(R.id.open_matrix_apps)?.let { menuItem ->
menuItem.actionView.setOnClickListener {
@ -915,6 +923,12 @@ class RoomDetailFragment @Inject constructor(
}
override fun onPrepareOptionsMenu(menu: Menu) {
if (isThreadTimeLine()) {
menu.forEach {
it.isVisible = false
}
return
}
menu.forEach {
it.isVisible = roomDetailViewModel.isMenuItemVisible(it.itemId)
}
@ -1180,6 +1194,12 @@ 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
@ -2203,4 +2223,7 @@ class RoomDetailFragment @Inject constructor(
}
}
}
fun isThreadTimeLine(): Boolean = roomDetailArgs.roomThreadDetailArgs != null
fun getRootThreadEventId(): String? = roomDetailArgs.roomThreadDetailArgs?.eventId
}

View file

@ -65,7 +65,8 @@ data class RoomDetailViewState(
val isAllowedToManageWidgets: Boolean = false,
val isAllowedToStartWebRTCCall: Boolean = true,
val hasFailedSending: Boolean = false,
val jitsiState: JitsiState = JitsiState()
val jitsiState: JitsiState = JitsiState(),
val rootThreadEventId: String? = null
) : MavericksState {
constructor(args: RoomDetailArgs) : this(

View file

@ -28,4 +28,6 @@ sealed class TextComposerAction : VectorViewModelAction {
data class UserIsTyping(val isTyping: Boolean) : TextComposerAction()
data class OnTextChanged(val text: CharSequence) : TextComposerAction()
data class OnVoiceRecordingStateChanged(val isRecording: Boolean) : TextComposerAction()
data class EnterReplyInThreadTimeline(val rootThreadEventId: String) : TextComposerAction()
}

View file

@ -42,6 +42,7 @@ import org.commonmark.renderer.html.HtmlRenderer
import org.matrix.android.sdk.api.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent
@ -88,6 +89,8 @@ class TextComposerViewModel @AssistedInject constructor(
is TextComposerAction.UserIsTyping -> handleUserIsTyping(action)
is TextComposerAction.OnTextChanged -> handleOnTextChanged(action)
is TextComposerAction.OnVoiceRecordingStateChanged -> handleOnVoiceRecordingStateChanged(action)
is TextComposerAction.EnterReplyInThreadTimeline -> handleEnterReplyInThreadTimeline(action)
}
}
@ -95,6 +98,10 @@ 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
@ -155,7 +162,11 @@ class TextComposerViewModel @AssistedInject constructor(
when (val slashCommandResult = CommandParser.parseSplashCommand(action.text)) {
is ParsedCommand.ErrorNotACommand -> {
// Send the text message to the room
if (state.rootThreadEventId != null)
room.replyInThread(state.rootThreadEventId, action.text.toString(), action.autoMarkdown)
else
room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown)
_viewEvents.post(TextComposerViewEvents.MessageSent)
popDraft()
}

View file

@ -46,6 +46,7 @@ data class TextComposerViewState(
val canSendMessage: Boolean = true,
val isVoiceRecording: Boolean = false,
val isSendButtonVisible: Boolean = false,
val rootThreadEventId: String? = null,
val sendMode: SendMode = SendMode.REGULAR("", false)
) : MavericksState {
@ -53,4 +54,6 @@ data class TextComposerViewState(
get() = canSendMessage && !isVoiceRecording
constructor(args: RoomDetailArgs) : this(roomId = args.roomId)
fun isInThreadTimeline(): Boolean = rootThreadEventId != null
}

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)
val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback, event.root.isRootThread)
// val all = event.root.toContent()
// val ev = all.toModel<Event>()

View file

@ -31,7 +31,8 @@ class MessageItemAttributesFactory @Inject constructor(
fun create(messageContent: Any?,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): AbsMessageItem.Attributes {
callback: TimelineEventController.Callback?,
isRootThread: Boolean = false): AbsMessageItem.Attributes {
return AbsMessageItem.Attributes(
avatarSize = avatarSizeProvider.avatarSize,
informationData = informationData,
@ -49,7 +50,8 @@ class MessageItemAttributesFactory @Inject constructor(
reactionPillCallback = callback,
avatarCallback = callback,
readReceiptsCallback = callback,
emojiTypeFace = emojiCompatFontProvider.typeface
emojiTypeFace = emojiCompatFontProvider.typeface,
isRootThread = isRootThread
)
}
}

View file

@ -98,6 +98,7 @@ 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
}
override fun unbind(holder: H) {
@ -117,6 +118,7 @@ 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)
}
/**
@ -133,7 +135,8 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
override val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null,
val avatarCallback: TimelineEventController.AvatarCallback? = null,
override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null,
val emojiTypeFace: Typeface? = null
val emojiTypeFace: Typeface? = null,
val isRootThread: Boolean = false
) : AbsBaseMessageItem.Attributes {
// Have to override as it's used to diff epoxy items

View file

@ -25,6 +25,8 @@ import im.vector.app.core.extensions.replaceFragment
import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivityRoomThreadDetailBinding
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.RoomDetailArgs
import im.vector.app.features.home.room.detail.RoomDetailFragment
import im.vector.app.features.home.room.threads.detail.arguments.RoomThreadDetailArgs
import org.matrix.android.sdk.api.util.MatrixItem
import javax.inject.Inject
@ -65,7 +67,16 @@ class RoomThreadDetailActivity : VectorBaseActivity<ActivityRoomThreadDetailBind
private fun initFragment() {
if (isFirstCreation()) {
replaceFragment(R.id.roomThreadDetailFragmentContainer, RoomThreadDetailFragment::class.java, getRoomThreadDetailArgs(), FRAGMENT_TAG)
getRoomThreadDetailArgs()?.let {
replaceFragment(
R.id.roomThreadDetailFragmentContainer,
RoomDetailFragment::class.java,
RoomDetailArgs(
roomId = it.roomId,
roomThreadDetailArgs = it
))
}
// replaceFragment(R.id.roomThreadDetailFragmentContainer, RoomThreadDetailFragment::class.java, getRoomThreadDetailArgs(), FRAGMENT_TAG)
}
}

View file

@ -47,7 +47,15 @@ class RoomThreadDetailFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initTextComposer()
// views.testTextVeiwddasda.text = "${roomThreadDetailArgs.eventId} -- ${roomThreadDetailArgs.roomId}"
// lifecycleScope.launch(Dispatchers.IO) {
// Realm.getInstance(realmConfiguration).executeTransaction {
// val eventId = roomThreadDetailArgs.eventId ?: return@executeTransaction
// val r = EventEntity.where(it, eventId = eventId)
// .findFirst() ?: return@executeTransaction
// Timber.i("------> $eventId isThread: ${EventMapper.map(r).isThread()}")
// }
// }
//// views.testTextVeiwddasda.text = "${roomThreadDetailArgs.eventId} -- ${roomThreadDetailArgs.roomId}"
}
private fun initTextComposer(){

View file

@ -200,4 +200,17 @@
</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" />
</RelativeLayout>