diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index bf78356947..cac35fb1fc 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -180,6 +180,7 @@ jobs: body="$(cat ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml | grep " + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/anim/animation_slide_in_right.xml b/library/ui-styles/src/main/res/anim/animation_slide_in_right.xml new file mode 100644 index 0000000000..cf7488cc1a --- /dev/null +++ b/library/ui-styles/src/main/res/anim/animation_slide_in_right.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/anim/animation_slide_out_left.xml b/library/ui-styles/src/main/res/anim/animation_slide_out_left.xml new file mode 100644 index 0000000000..2afa66ceab --- /dev/null +++ b/library/ui-styles/src/main/res/anim/animation_slide_out_left.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/anim/animation_slide_out_right.xml b/library/ui-styles/src/main/res/anim/animation_slide_out_right.xml new file mode 100644 index 0000000000..49348f1dac --- /dev/null +++ b/library/ui-styles/src/main/res/anim/animation_slide_out_right.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/values-v23/dimens.xml b/library/ui-styles/src/main/res/values-v23/dimens.xml new file mode 100644 index 0000000000..18b8a81a7e --- /dev/null +++ b/library/ui-styles/src/main/res/values-v23/dimens.xml @@ -0,0 +1,4 @@ + + + 28dp + \ No newline at end of file diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml index d184cd06df..7e79218281 100644 --- a/library/ui-styles/src/main/res/values/dimens.xml +++ b/library/ui-styles/src/main/res/values/dimens.xml @@ -43,6 +43,10 @@ 8dp + 24dp + 48dp + 48dp + 56dp 52dp diff --git a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt index 42c1476b79..826f584f6a 100644 --- a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt +++ b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt @@ -32,6 +32,8 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional +typealias ThreadRootEvent = TimelineEvent + class FlowRoom(private val room: Room) { fun liveRoomSummary(): Flow> { @@ -98,6 +100,20 @@ class FlowRoom(private val room: Room) { fun liveNotificationState(): Flow { return room.getLiveRoomNotificationState().asFlow() } + + fun liveThreadList(): Flow> { + return room.getAllThreadsLive().asFlow() + .startWith(room.coroutineDispatchers.io) { + room.getAllThreads() + } + } + + fun liveLocalUnreadThreadList(): Flow> { + return room.getMarkedThreadNotificationsLive().asFlow() + .startWith(room.coroutineDispatchers.io) { + room.getMarkedThreadNotifications() + } + } } fun Room.flow(): FlowRoom { diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index a4102f7441..7d4bd0bc67 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -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' } @@ -62,8 +64,8 @@ android { } } - adbOptions { - installOptions "-g" + installation { + installOptions '-g' // timeOutInMs 350 * 1000 } @@ -139,6 +141,9 @@ dependencies { kapt 'dk.ilios:realmfieldnameshelper:2.0.0' + // Shared Preferences + implementation libs.androidx.preferenceKtx + // Work implementation libs.androidx.work diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt index 3cb699378f..031d0a8bcf 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt @@ -157,14 +157,20 @@ class CommonTestHelper(context: Context) { /** * Will send nb of messages provided by count parameter but waits every 10 messages to avoid gap in sync */ - private fun sendTextMessagesBatched(timeline: Timeline, room: Room, message: String, count: Int, timeout: Long): List { + private fun sendTextMessagesBatched(timeline: Timeline, room: Room, message: String, count: Int, timeout: Long, rootThreadEventId: String? = null): List { val sentEvents = ArrayList(count) (1 until count + 1) .map { "$message #$it" } .chunked(10) .forEach { batchedMessages -> batchedMessages.forEach { formattedMessage -> - room.sendTextMessage(formattedMessage) + if (rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = rootThreadEventId, + replyInThreadText = formattedMessage) + } else { + room.sendTextMessage(formattedMessage) + } } waitWithLatch(timeout) { latch -> val timelineListener = object : Timeline.Listener { @@ -196,6 +202,27 @@ class CommonTestHelper(context: Context) { return sentEvents } + /** + * Reply in a thread + * @param room the room where to send the messages + * @param message the message to send + * @param numberOfMessages the number of time the message will be sent + */ + fun replyInThreadMessage( + room: Room, + message: String, + numberOfMessages: Int, + rootThreadEventId: String, + timeout: Long = TestConstants.timeOutMillis): List { + val timeline = room.createTimeline(null, TimelineSettings(10)) + timeline.start() + val sentEvents = sendTextMessagesBatched(timeline, room, message, numberOfMessages, timeout, rootThreadEventId) + timeline.dispose() + // Check that all events has been created + assertEquals("Message number do not match $sentEvents", numberOfMessages.toLong(), sentEvents.size.toLong()) + return sentEvents + } + // PRIVATE METHODS ***************************************************************************** /** diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/threads/ThreadMessagingTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/threads/ThreadMessagingTest.kt new file mode 100644 index 0000000000..6aa4f4cc32 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/threads/ThreadMessagingTest.kt @@ -0,0 +1,339 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.session.room.threads + +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeNull +import org.amshove.kluent.shouldBeTrue +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId +import org.matrix.android.sdk.api.session.events.model.isTextMessage +import org.matrix.android.sdk.api.session.events.model.isThread +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.CryptoTestHelper +import java.util.concurrent.CountDownLatch + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class ThreadMessagingTest : InstrumentedTest { + + @Test + fun reply_in_thread_should_create_a_thread() { + val commonTestHelper = CommonTestHelper(context()) + val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + + val aliceRoom = aliceSession.getRoom(aliceRoomId)!! + + // Let's send a message in the normal timeline + val textMessage = "This is a normal timeline message" + val sentMessages = commonTestHelper.sendTextMessage( + room = aliceRoom, + message = textMessage, + nbOfMessages = 1) + + val initMessage = sentMessages.first() + + initMessage.root.isThread().shouldBeFalse() + initMessage.root.isTextMessage().shouldBeTrue() + initMessage.root.getRootThreadEventId().shouldBeNull() + initMessage.root.threadDetails?.isRootThread?.shouldBeFalse() + + // Let's reply in timeline to that message + val repliesInThread = commonTestHelper.replyInThreadMessage( + room = aliceRoom, + message = "Reply In the above thread", + numberOfMessages = 1, + rootThreadEventId = initMessage.root.eventId.orEmpty()) + + val replyInThread = repliesInThread.first() + replyInThread.root.isThread().shouldBeTrue() + replyInThread.root.isTextMessage().shouldBeTrue() + replyInThread.root.getRootThreadEventId().shouldBeEqualTo(initMessage.root.eventId) + + // The init normal message should now be a root thread event + val timeline = aliceRoom.createTimeline(null, TimelineSettings(30)) + timeline.start() + + aliceSession.startSync(true) + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + val initMessageThreadDetails = snapshot.firstOrNull { + it.root.eventId == initMessage.root.eventId + }?.root?.threadDetails + initMessageThreadDetails?.isRootThread?.shouldBeTrue() + initMessageThreadDetails?.numberOfThreads?.shouldBe(1) + true + } + timeline.addListener(eventsListener) + commonTestHelper.await(lock, 600_000) + } + aliceSession.stopSync() + } + + @Test + fun reply_in_thread_should_create_a_thread_from_other_user() { + val commonTestHelper = CommonTestHelper(context()) + val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + val aliceRoom = aliceSession.getRoom(aliceRoomId)!! + + // Let's send a message in the normal timeline + val textMessage = "This is a normal timeline message" + val sentMessages = commonTestHelper.sendTextMessage( + room = aliceRoom, + message = textMessage, + nbOfMessages = 1) + + val initMessage = sentMessages.first() + + initMessage.root.isThread().shouldBeFalse() + initMessage.root.isTextMessage().shouldBeTrue() + initMessage.root.getRootThreadEventId().shouldBeNull() + initMessage.root.threadDetails?.isRootThread?.shouldBeFalse() + + // Let's reply in timeline to that message from another user + val bobSession = cryptoTestData.secondSession!! + val bobRoomId = cryptoTestData.roomId + val bobRoom = bobSession.getRoom(bobRoomId)!! + + val repliesInThread = commonTestHelper.replyInThreadMessage( + room = bobRoom, + message = "Reply In the above thread", + numberOfMessages = 1, + rootThreadEventId = initMessage.root.eventId.orEmpty()) + + val replyInThread = repliesInThread.first() + replyInThread.root.isThread().shouldBeTrue() + replyInThread.root.isTextMessage().shouldBeTrue() + replyInThread.root.getRootThreadEventId().shouldBeEqualTo(initMessage.root.eventId) + + // The init normal message should now be a root thread event + val timeline = aliceRoom.createTimeline(null, TimelineSettings(30)) + timeline.start() + + aliceSession.startSync(true) + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + val initMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == initMessage.root.eventId }?.root?.threadDetails + initMessageThreadDetails?.isRootThread?.shouldBeTrue() + initMessageThreadDetails?.numberOfThreads?.shouldBe(1) + true + } + timeline.addListener(eventsListener) + commonTestHelper.await(lock, 600_000) + } + aliceSession.stopSync() + + bobSession.startSync(true) + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + val initMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == initMessage.root.eventId }?.root?.threadDetails + initMessageThreadDetails?.isRootThread?.shouldBeTrue() + initMessageThreadDetails?.numberOfThreads?.shouldBe(1) + true + } + timeline.addListener(eventsListener) + commonTestHelper.await(lock, 600_000) + } + bobSession.stopSync() + } + + @Test + fun reply_in_thread_to_timeline_message_multiple_times() { + val commonTestHelper = CommonTestHelper(context()) + val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + + val aliceRoom = aliceSession.getRoom(aliceRoomId)!! + + // Let's send 5 messages in the normal timeline + val textMessage = "This is a normal timeline message" + val sentMessages = commonTestHelper.sendTextMessage( + room = aliceRoom, + message = textMessage, + nbOfMessages = 5) + + sentMessages.forEach { + it.root.isThread().shouldBeFalse() + it.root.isTextMessage().shouldBeTrue() + it.root.getRootThreadEventId().shouldBeNull() + it.root.threadDetails?.isRootThread?.shouldBeFalse() + } + // let's start the thread from the second message + val selectedInitMessage = sentMessages[1] + + // Let's reply 40 times in the timeline to the second message + val repliesInThread = commonTestHelper.replyInThreadMessage( + room = aliceRoom, + message = "Reply In the above thread", + numberOfMessages = 40, + rootThreadEventId = selectedInitMessage.root.eventId.orEmpty()) + + repliesInThread.forEach { + it.root.isThread().shouldBeTrue() + it.root.isTextMessage().shouldBeTrue() + it.root.getRootThreadEventId()?.shouldBeEqualTo(selectedInitMessage.root.eventId.orEmpty()) ?: assert(false) + } + + // The init normal message should now be a root thread event + val timeline = aliceRoom.createTimeline(null, TimelineSettings(30)) + timeline.start() + + aliceSession.startSync(true) + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + val initMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == selectedInitMessage.root.eventId }?.root?.threadDetails + // Selected init message should be the thread root + initMessageThreadDetails?.isRootThread?.shouldBeTrue() + // All threads should be 40 + initMessageThreadDetails?.numberOfThreads?.shouldBeEqualTo(40) + true + } + // Because we sent more than 30 messages we should paginate a bit more + timeline.paginate(Timeline.Direction.BACKWARDS, 50) + timeline.addListener(eventsListener) + commonTestHelper.await(lock, 600_000) + } + aliceSession.stopSync() + } + + @Test + fun thread_summary_advanced_validation_after_multiple_messages_in_multiple_threads() { + val commonTestHelper = CommonTestHelper(context()) + val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) + + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + + val aliceRoom = aliceSession.getRoom(aliceRoomId)!! + + // Let's send 5 messages in the normal timeline + val textMessage = "This is a normal timeline message" + val sentMessages = commonTestHelper.sendTextMessage( + room = aliceRoom, + message = textMessage, + nbOfMessages = 5) + + sentMessages.forEach { + it.root.isThread().shouldBeFalse() + it.root.isTextMessage().shouldBeTrue() + it.root.getRootThreadEventId().shouldBeNull() + it.root.threadDetails?.isRootThread?.shouldBeFalse() + } + // let's start the thread from the second message + val firstMessage = sentMessages[0] + val secondMessage = sentMessages[1] + + // Alice will reply in thread to the second message 35 times + val aliceThreadRepliesInSecondMessage = commonTestHelper.replyInThreadMessage( + room = aliceRoom, + message = "Alice reply In the above second thread message", + numberOfMessages = 35, + rootThreadEventId = secondMessage.root.eventId.orEmpty()) + + // Let's reply in timeline to that message from another user + val bobSession = cryptoTestData.secondSession!! + val bobRoomId = cryptoTestData.roomId + val bobRoom = bobSession.getRoom(bobRoomId)!! + + // Bob will reply in thread to the first message 35 times + val bobThreadRepliesInFirstMessage = commonTestHelper.replyInThreadMessage( + room = bobRoom, + message = "Bob reply In the above first thread message", + numberOfMessages = 42, + rootThreadEventId = firstMessage.root.eventId.orEmpty()) + + // Bob will also reply in second thread 5 times + val bobThreadRepliesInSecondMessage = commonTestHelper.replyInThreadMessage( + room = bobRoom, + message = "Another Bob reply In the above second thread message", + numberOfMessages = 20, + rootThreadEventId = secondMessage.root.eventId.orEmpty()) + + aliceThreadRepliesInSecondMessage.forEach { + it.root.isThread().shouldBeTrue() + it.root.isTextMessage().shouldBeTrue() + it.root.getRootThreadEventId()?.shouldBeEqualTo(secondMessage.root.eventId.orEmpty()) ?: assert(false) + } + + bobThreadRepliesInFirstMessage.forEach { + it.root.isThread().shouldBeTrue() + it.root.isTextMessage().shouldBeTrue() + it.root.getRootThreadEventId()?.shouldBeEqualTo(firstMessage.root.eventId.orEmpty()) ?: assert(false) + } + + bobThreadRepliesInSecondMessage.forEach { + it.root.isThread().shouldBeTrue() + it.root.isTextMessage().shouldBeTrue() + it.root.getRootThreadEventId()?.shouldBeEqualTo(secondMessage.root.eventId.orEmpty()) ?: assert(false) + } + + // The init normal message should now be a root thread event + val timeline = aliceRoom.createTimeline(null, TimelineSettings(30)) + timeline.start() + + aliceSession.startSync(true) + run { + val lock = CountDownLatch(1) + val eventsListener = commonTestHelper.createEventListener(lock) { snapshot -> + val firstMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == firstMessage.root.eventId }?.root?.threadDetails + val secondMessageThreadDetails = snapshot.firstOrNull { it.root.eventId == secondMessage.root.eventId }?.root?.threadDetails + + // first & second message should be the thread root + firstMessageThreadDetails?.isRootThread?.shouldBeTrue() + secondMessageThreadDetails?.isRootThread?.shouldBeTrue() + + // First thread message should contain 42 + firstMessageThreadDetails?.numberOfThreads shouldBeEqualTo 42 + // Second thread message should contain 35+20 + secondMessageThreadDetails?.numberOfThreads shouldBeEqualTo 55 + + true + } + // Because we sent more than 30 messages we should paginate a bit more + timeline.paginate(Timeline.Direction.BACKWARDS, 50) + timeline.paginate(Timeline.Direction.BACKWARDS, 50) + timeline.addListener(eventsListener) + commonTestHelper.await(lock, 600_000) + } + aliceSession.stopSync() + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index aad5fce33e..df57ca5681 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt @@ -25,9 +25,14 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError 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.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent 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.model.relation.shouldRenderInThread 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.ContentUtils 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 @@ -98,6 +103,9 @@ data class Event( @Transient var sendStateDetails: String? = null + @Transient + var threadDetails: ThreadDetails? = null + fun sendStateError(): MatrixError? { return sendStateDetails?.let { val matrixErrorAdapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java) @@ -123,6 +131,7 @@ data class Event( it.mCryptoErrorReason = mCryptoErrorReason it.sendState = sendState it.ageLocalTs = ageLocalTs + it.threadDetails = threadDetails } } @@ -185,6 +194,51 @@ data class Event( return contentMap?.let { JSONObject(adapter.toJson(it)).toString(4) } } + /** + * Returns a user friendly content depending on the message type. + * It can be used especially for message summaries. + * It will return a decrypted text message or an empty string otherwise. + */ + fun getDecryptedTextSummary(): String? { + if (isRedacted()) return "Message Deleted" + val text = getDecryptedValue() ?: return null + return when { + isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text) + isFileMessage() -> "sent a file." + isAudioMessage() -> "sent an audio file." + isImageMessage() -> "sent an image." + isVideoMessage() -> "sent a video." + isSticker() -> "sent a sticker" + isPoll() -> getPollQuestion() ?: "created a poll." + else -> text + } + } + + private fun Event.isQuote(): Boolean { + if (isReplyRenderedInThread()) return false + return getDecryptedValue("formatted_body")?.contains("
") ?: false + } + + /** + * Determines whether or not current event has mentioned the user + */ + fun isUserMentioned(userId: String): Boolean { + return getDecryptedValue("formatted_body")?.contains(userId) ?: false + } + + /** + * Decrypt the message, or return the pure payload value if there is no encryption + */ + private fun getDecryptedValue(key: String = "body"): String? { + return if (isEncrypted()) { + @Suppress("UNCHECKED_CAST") + val decryptedContent = mxDecryptionResult?.payload?.get("content") as? JsonDict + decryptedContent?.get(key) as? String + } else { + content?.get(key) as? String + } + } + /** * Tells if the event is redacted */ @@ -217,7 +271,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 } @@ -236,6 +290,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 } } @@ -243,70 +299,101 @@ data class Event( fun Event.isTextMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) { - MessageType.MSGTYPE_TEXT, - MessageType.MSGTYPE_EMOTE, - MessageType.MSGTYPE_NOTICE -> true - else -> false - } + MessageType.MSGTYPE_TEXT, + MessageType.MSGTYPE_EMOTE, + MessageType.MSGTYPE_NOTICE -> true + else -> false + } } fun Event.isImageMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) { - MessageType.MSGTYPE_IMAGE -> true - else -> false - } + MessageType.MSGTYPE_IMAGE -> true + else -> false + } } fun Event.isVideoMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) { - MessageType.MSGTYPE_VIDEO -> true - else -> false - } + MessageType.MSGTYPE_VIDEO -> true + else -> false + } } fun Event.isAudioMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) { - MessageType.MSGTYPE_AUDIO -> true - else -> false - } + MessageType.MSGTYPE_AUDIO -> true + else -> false + } } fun Event.isFileMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) { - MessageType.MSGTYPE_FILE -> true - else -> false - } + MessageType.MSGTYPE_FILE -> true + else -> false + } } fun Event.isAttachmentMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.get(MessageContent.MSG_TYPE_JSON_KEY)) { - MessageType.MSGTYPE_IMAGE, - MessageType.MSGTYPE_AUDIO, - MessageType.MSGTYPE_VIDEO, - MessageType.MSGTYPE_FILE -> true - else -> false - } + MessageType.MSGTYPE_IMAGE, + MessageType.MSGTYPE_AUDIO, + MessageType.MSGTYPE_VIDEO, + MessageType.MSGTYPE_FILE -> true + else -> false + } } +fun Event.isPoll(): Boolean = getClearType() == EventType.POLL_START || getClearType() == EventType.POLL_END + +fun Event.isSticker(): Boolean = getClearType() == EventType.STICKER + fun Event.getRelationContent(): RelationDefaultContent? { return if (isEncrypted()) { content.toModel()?.relatesTo } else { - content.toModel()?.relatesTo + content.toModel()?.relatesTo ?: run { + // Special case to handle stickers, while there is only a local msgtype for stickers + if (getClearType() == EventType.STICKER) { + getClearContent().toModel()?.relatesTo + } else { + null + } + } } } +/** + * Returns the poll question or null otherwise + */ +fun Event.getPollQuestion(): String? = + getPollContent()?.pollCreationInfo?.question?.question + +/** + * 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.isReplyRenderedInThread(): Boolean { + return isReply() && getRelationContent()?.inReplyTo?.shouldRenderInThread() == true +} + +fun Event.isThread(): Boolean = getRelationContentForType(RelationType.IO_THREAD)?.eventId != null + +fun Event.getRootThreadEventId(): String? = getRelationContentForType(RelationType.IO_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? { @@ -315,3 +402,7 @@ fun Event.getPresenceContent(): PresenceContent? { fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER && content?.toModel()?.membership == Membership.INVITE + +fun Event.getPollContent(): MessagePollContent? { + return content.toModel() +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt index f67efc50ba..fb26264ad7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/RelationType.kt @@ -28,9 +28,9 @@ object RelationType { /** Lets you define an event which references an existing event.*/ const val REFERENCE = "m.reference" - /** Lets you define an thread event that belongs to another existing event.*/ -// const val THREAD = "m.thread" // m.thread is not yet released in the backend - const val THREAD = "io.element.thread" // io.element.thread will be replaced by m.thread when it is released + /** Lets you define an event which is a thread reply to an existing event.*/ + const val THREAD = "m.thread" + const val IO_THREAD = "io.element.thread" /** Lets you define an event which adds a response to an existing event.*/ const val RESPONSE = "org.matrix.response" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt index 6c0e730499..d930a5d0fd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/Room.kt @@ -32,6 +32,7 @@ import org.matrix.android.sdk.api.session.room.send.DraftService import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.session.room.tags.TagsService +import org.matrix.android.sdk.api.session.room.threads.ThreadsService import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.uploads.UploadsService @@ -45,6 +46,7 @@ import org.matrix.android.sdk.api.util.Optional */ interface Room : TimelineService, + ThreadsService, SendService, DraftService, ReadService, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt index 763d4bb892..9f2850e26a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room.model.relation import androidx.lifecycle.LiveData import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary +import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.PollType import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.Cancelable @@ -45,6 +46,9 @@ import org.matrix.android.sdk.api.util.Optional * m.reference - lets you define an event which references an existing event. * When aggregated, currently doesn't do anything special, but in future could bundle chains of references (i.e. threads). * These are primarily intended for handling replies (and in future threads). + * + * m.thread - lets you define an event which is a thread reply to an existing event. + * When aggregated, returns the most thread event */ interface RelationService { @@ -118,10 +122,15 @@ interface RelationService { * @param eventReplied the event referenced by the reply * @param replyText the reply text * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present + * @param showInThread If true, relation will be added to the reply in order to be visible from within threads + * @param rootThreadEventId If show in thread is true then we need the rootThreadEventId to generate the relation */ fun replyToMessage(eventReplied: TimelineEvent, replyText: CharSequence, - autoMarkdown: Boolean = false): Cancelable? + autoMarkdown: Boolean = false, + showInThread: Boolean = false, + rootThreadEventId: String? = null + ): Cancelable? /** * Get the current EventAnnotationsSummary @@ -136,4 +145,31 @@ interface RelationService { * @return the LiveData of EventAnnotationsSummary */ fun getEventAnnotationsSummaryLive(eventId: String): LiveData> + + /** + * 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 rootThreadEventId the root thread eventId + * @param replyInThreadText the reply text + * @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE + * @param formattedText The formatted body using MessageType#FORMAT_MATRIX_HTML + * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present + * @param eventReplied the event referenced by the reply within a thread + */ + fun replyInThread(rootThreadEventId: String, + replyInThreadText: CharSequence, + msgType: String = MessageType.MSGTYPE_TEXT, + autoMarkdown: Boolean = false, + formattedText: String? = null, + eventReplied: TimelineEvent? = null): Cancelable? + + /** + * Get all the thread replies for the specified rootThreadEventId + * The return list will contain the original root thread event and all the thread replies to that event + * Note: We will use a large limit value in order to avoid using pagination until it would be 100% ready + * from the backend + * @param rootThreadEventId the root thread eventId + */ + suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt index 251328bea2..412a1bfca9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/ReplyToContent.kt @@ -21,5 +21,8 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class ReplyToContent( - @Json(name = "event_id") val eventId: String? = null + @Json(name = "event_id") val eventId: String? = null, + @Json(name = "render_in") val renderIn: List? = null ) + +fun ReplyToContent.shouldRenderInThread(): Boolean = renderIn?.contains("m.thread") == true diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt index 20d00394df..913dbfd010 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt @@ -64,7 +64,7 @@ interface SendService { * @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present * @return a [Cancelable] */ - fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean): Cancelable + fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String? = null): Cancelable /** * Method to send a media asynchronously. @@ -72,11 +72,13 @@ interface SendService { * @param compressBeforeSending set to true to compress images before sending them * @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present. * It can be useful to send media to multiple room. It's safe to include the current roomId in this set + * @param rootThreadEventId when this param is not null, the Media will be sent in this specific thread * @return a [Cancelable] */ fun sendMedia(attachment: ContentAttachmentData, compressBeforeSending: Boolean, - roomIds: Set): Cancelable + roomIds: Set, + rootThreadEventId: String? = null): Cancelable /** * Method to send a list of media asynchronously. @@ -84,11 +86,13 @@ interface SendService { * @param compressBeforeSending set to true to compress images before sending them * @param roomIds set of roomIds to where the media will be sent. The current roomId will be add to this set if not present. * It can be useful to send media to multiple room. It's safe to include the current roomId in this set + * @param rootThreadEventId when this param is not null, all the Media will be sent in this specific thread * @return a [Cancelable] */ fun sendMedias(attachments: List, compressBeforeSending: Boolean, - roomIds: Set): Cancelable + roomIds: Set, + rootThreadEventId: String? = null): Cancelable /** * Send a poll to the room. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt new file mode 100644 index 0000000000..e4d1d979e1 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.api.session.room.threads + +import androidx.lifecycle.LiveData +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +/** + * This interface defines methods to interact with threads related features. + * It's implemented at the room level within the main timeline. + */ +interface ThreadsService { + + /** + * Returns a [LiveData] list of all the thread root TimelineEvents that exists at the room level + */ + fun getAllThreadsLive(): LiveData> + + /** + * Returns a list of all the thread root TimelineEvents that exists at the room level + */ + fun getAllThreads(): List + + /** + * Returns a [LiveData] list of all the marked unread threads that exists at the room level + */ + fun getMarkedThreadNotificationsLive(): LiveData> + + /** + * Returns a list of all the marked unread threads that exists at the room level + */ + fun getMarkedThreadNotifications(): List + + /** + * Returns whether or not the current user is participating in the thread + * @param rootThreadEventId the eventId of the current thread + */ + fun isUserParticipatingInThread(rootThreadEventId: String): Boolean + + /** + * Enhance the provided root thread TimelineEvent [List] by adding the latest + * message edition for that thread + * @return the enhanced [List] with edited updates + */ + fun mapEventsWithEdition(threads: List): List + + /** + * Marks the current thread as read in local DB. + * note: read receipts within threads are not yet supported with the API + * @param rootThreadEventId the root eventId of the current thread + */ + suspend fun markThreadAsRead(rootThreadEventId: String) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt index 241e5f3b9b..d47a656798 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/Timeline.kt @@ -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. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index 3f7d2d1278..6f8bae876b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt @@ -22,7 +22,9 @@ 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.isEdition +import org.matrix.android.sdk.api.session.events.model.isPoll import org.matrix.android.sdk.api.session.events.model.isReply +import org.matrix.android.sdk.api.session.events.model.isSticker import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary import org.matrix.android.sdk.api.session.room.model.ReadReceipt @@ -149,6 +151,13 @@ fun TimelineEvent.isEdition(): Boolean { return root.isEdition() } +fun TimelineEvent.isPoll(): Boolean = + root.isPoll() + +fun TimelineEvent.isSticker(): Boolean { + return root.isSticker() +} + /** * Get the latest message body, after a possible edition, stripping the reply prefix if necessary */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineSettings.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineSettings.kt index ceffedb234..6548453c8a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineSettings.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineSettings.kt @@ -27,5 +27,14 @@ data class TimelineSettings( /** * If true, will build read receipts for each event. */ - val buildReadReceipts: Boolean = true -) + val buildReadReceipts: Boolean = true, + /** + * The root thread eventId if this is a thread timeline, or null if this is NOT a thread timeline + */ + val rootThreadEventId: String? = null) { + + /** + * Returns true if this is a thread timeline or false otherwise + */ + fun isThreadTimeline() = rootThreadEventId != null +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt new file mode 100644 index 0000000000..fafe17b2c0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadDetails.kt @@ -0,0 +1,34 @@ +/* + * 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 + +/** + * This class contains all the details needed for threads. + * Is is mainly used from within an Event. + */ +data class ThreadDetails( + val isRootThread: Boolean = false, + val numberOfThreads: Int = 0, + val threadSummarySenderInfo: SenderInfo? = null, + val threadSummaryLatestTextMessage: String? = null, + val lastMessageTimestamp: Long? = null, + var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE, + val isThread: Boolean = false, + val lastRootThreadEdition: String? = null +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadNotificationBadgeState.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadNotificationBadgeState.kt new file mode 100644 index 0000000000..8e861e73de --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadNotificationBadgeState.kt @@ -0,0 +1,25 @@ +/* + * 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 + +/** + * This class defines the state of a thread notification badge + */ +data class ThreadNotificationBadgeState( + val numberOfLocalUnreadThreads: Int = 0, + val isUserMentioned: Boolean = false +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadNotificationState.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadNotificationState.kt new file mode 100644 index 0000000000..8566d68aa5 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadNotificationState.kt @@ -0,0 +1,33 @@ +/* + * 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 + +/** + * This class defines the state of a thread notification + */ +enum class ThreadNotificationState { + + // There are no new message + NO_NEW_MESSAGE, + + // There is at least one new message + NEW_MESSAGE, + + // The is at least one new message that should be highlighted + // ex. "Hello @aris.kotsomitopoulos" + NEW_HIGHLIGHTED_MESSAGE; +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadTimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadTimelineEvent.kt new file mode 100644 index 0000000000..7b433566b8 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/threads/ThreadTimelineEvent.kt @@ -0,0 +1,28 @@ +/* + * 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.timeline.TimelineEvent + +/** + * This class contains a thread TimelineEvent along with a boolean that + * determines if the current user has participated in that event + */ +data class ThreadTimelineEvent( + val timelineEvent: TimelineEvent, + val isParticipating: Boolean +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXOutboundSessionInfo.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXOutboundSessionInfo.kt index 5a68937868..b70e6c1f80 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXOutboundSessionInfo.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/algorithms/megolm/MXOutboundSessionInfo.kt @@ -35,7 +35,7 @@ internal class MXOutboundSessionInfo( val sessionLifetime = System.currentTimeMillis() - creationTime if (useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) { - Timber.v("## needsRotation() : Rotating megolm session after " + useCount + ", " + sessionLifetime + "ms") + Timber.v("## needsRotation() : Rotating megolm session after $useCount, ${sessionLifetime}ms") needsRotation = true } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index 1f45ac2a75..e7ccae38d5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -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.api.session.threads.ThreadNotificationState import org.matrix.android.sdk.internal.crypto.model.event.EncryptionEventContent import org.matrix.android.sdk.internal.database.model.ChunkEntityFields import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntityFields @@ -56,7 +57,7 @@ internal class RealmSessionStoreMigration @Inject constructor( ) : RealmMigration { companion object { - const val SESSION_STORE_SCHEMA_VERSION = 21L + const val SESSION_STORE_SCHEMA_VERSION = 22L } /** @@ -90,6 +91,7 @@ internal class RealmSessionStoreMigration @Inject constructor( if (oldVersion <= 18) migrateTo19(realm) if (oldVersion <= 19) migrateTo20(realm) if (oldVersion <= 20) migrateTo21(realm) + if (oldVersion <= 21) migrateTo22(realm) } private fun migrateTo1(realm: DynamicRealm) { @@ -445,4 +447,19 @@ internal class RealmSessionStoreMigration @Inject constructor( } } } + + private fun migrateTo22(realm: DynamicRealm) { + Timber.d("Step 21 -> 22") + val eventEntity = realm.schema.get("TimelineEventEntity") ?: return + + realm.schema.get("EventEntity") + ?.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) + ?.addField(EventEntityFields.THREAD_NOTIFICATION_STATE_STR, String::class.java) + ?.transform { + it.setString(EventEntityFields.THREAD_NOTIFICATION_STATE_STR, ThreadNotificationState.NO_NEW_MESSAGE.name) + } + ?.addRealmObjectField(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.`$`, eventEntity) + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt index c21bf74d93..289db9fa15 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt @@ -34,6 +34,7 @@ import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields import org.matrix.android.sdk.internal.database.query.find 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.whereRoomId import org.matrix.android.sdk.internal.extensions.assertIsManaged import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection import timber.log.Timber @@ -81,7 +82,7 @@ internal fun ChunkEntity.addStateEvent(roomId: String, stateEvent: EventEntity, internal fun ChunkEntity.addTimelineEvent(roomId: String, eventEntity: EventEntity, direction: PaginationDirection, - roomMemberContentsByUser: Map) { + roomMemberContentsByUser: Map? = null) { val eventId = eventEntity.eventId if (timelineEvents.find(eventId) != null) { return @@ -101,7 +102,7 @@ internal fun ChunkEntity.addTimelineEvent(roomId: String, ?.also { it.cleanUp(eventEntity.sender) } this.readReceipts = readReceiptsSummaryEntity this.displayIndex = displayIndex - val roomMemberContent = roomMemberContentsByUser[senderId] + val roomMemberContent = roomMemberContentsByUser?.get(senderId) this.senderAvatar = roomMemberContent?.avatarUrl this.senderName = roomMemberContent?.displayName isUniqueDisplayName = if (roomMemberContent?.displayName != null) { @@ -157,9 +158,21 @@ private fun ChunkEntity.addTimelineEventFromMerge(realm: Realm, timelineEventEnt this.senderName = timelineEventEntity.senderName this.isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName } + handleThreadSummary(realm, eventId, copied) timelineEvents.add(copied) } +/** + * Upon copy of the timeline events we should update the latestMessage TimelineEventEntity with the new one + */ +private fun handleThreadSummary(realm: Realm, oldEventId: String, newTimelineEventEntity: TimelineEventEntity) { + EventEntity + .whereRoomId(realm, newTimelineEventEntity.roomId) + .equalTo(EventEntityFields.IS_ROOT_THREAD, true) + .equalTo(EventEntityFields.THREAD_SUMMARY_LATEST_MESSAGE.EVENT_ID, oldEventId) + .findFirst()?.threadSummaryLatestMessage = newTimelineEventEntity +} + private fun handleReadReceipts(realm: Realm, roomId: String, eventEntity: EventEntity, senderId: String): ReadReceiptsSummaryEntity { val readReceiptsSummaryEntity = ReadReceiptsSummaryEntity.where(realm, eventEntity.eventId).findFirst() ?: realm.createObject(eventEntity.eventId).apply { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt new file mode 100644 index 0000000000..f703bfaf82 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt @@ -0,0 +1,321 @@ +/* + * 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.RealmQuery +import io.realm.Sort +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.internal.database.mapper.asDomain +import org.matrix.android.sdk.internal.database.model.ChunkEntity +import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.ReadReceiptEntity +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.find +import org.matrix.android.sdk.internal.database.query.findIncludingEvent +import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.database.query.whereRoomId + +private typealias ThreadSummary = Pair? + +/** + * 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.updateThreadSummaryIfNeeded( + roomId: String, + realm: Realm, currentUserId: String, + chunkEntity: ChunkEntity? = null, + shouldUpdateNotifications: Boolean = true) { + for ((rootThreadEventId, eventEntity) in this) { + eventEntity.threadSummaryInThread(eventEntity.realm, rootThreadEventId, chunkEntity)?.let { threadSummary -> + + val numberOfMessages = threadSummary.first + val latestEventInThread = threadSummary.second + + // If this is a thread message, find its root event if exists + val rootThreadEvent = if (eventEntity.isThread()) eventEntity.findRootThreadEvent() else eventEntity + + rootThreadEvent?.markEventAsRoot( + threadsCounted = numberOfMessages, + latestMessageTimelineEventEntity = latestEventInThread + ) + } + } + + if (shouldUpdateNotifications) { + updateNotificationsNew(roomId, realm, currentUserId) + } +} + +/** + * 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 +} + +/** + * Count the number of threads for the provided root thread eventId, and finds the latest event message + * @param rootThreadEventId The root eventId that will find the number of threads + * @return A ThreadSummary containing the counted threads and the latest event message + */ +internal fun EventEntity.threadSummaryInThread(realm: Realm, rootThreadEventId: String, chunkEntity: ChunkEntity?): ThreadSummary { + // Number of messages + val messages = TimelineEventEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .count() + .toInt() + + if (messages <= 0) return null + + // Find latest thread event, we know it exists + var chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) ?: chunkEntity ?: return null + var result: TimelineEventEntity? = null + + // Iterate the chunk until we find our latest event + while (result == null) { + result = findLatestSortedChunkEvent(chunk, rootThreadEventId) + chunk = ChunkEntity.find(realm, roomId, nextToken = chunk.prevToken) ?: break + } + + if (result == null && chunkEntity != null) { + // Find latest event from our current chunk + result = findLatestSortedChunkEvent(chunkEntity, rootThreadEventId) + } else if (result != null && chunkEntity != null) { + val currentChunkLatestEvent = findLatestSortedChunkEvent(chunkEntity, rootThreadEventId) + result = findMostRecentEvent(result, currentChunkLatestEvent) + } + + result ?: return null + + return ThreadSummary(messages, result) +} + +/** + * Lets compare them in case user is moving forward in the timeline and we cannot know the + * exact chunk sequence while currentChunk is not yet committed in the DB + */ +private fun findMostRecentEvent(result: TimelineEventEntity, currentChunkLatestEvent: TimelineEventEntity?): TimelineEventEntity { + currentChunkLatestEvent ?: return result + val currentChunkEventTimestamp = currentChunkLatestEvent.root?.originServerTs ?: return result + val resultTimestamp = result.root?.originServerTs ?: return result + if (currentChunkEventTimestamp > resultTimestamp) { + return currentChunkLatestEvent + } + return result +} + +/** + * Find the latest event of the current chunk + */ +private fun findLatestSortedChunkEvent(chunk: ChunkEntity, rootThreadEventId: String): TimelineEventEntity? = + chunk.timelineEvents.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)?.firstOrNull { + it.root?.rootThreadEventId == rootThreadEventId + } + +/** + * Find all TimelineEventEntity that are root threads for the specified room + * @param roomId The room that all stored root threads will be returned + */ +internal fun TimelineEventEntity.Companion.findAllThreadsForRoomId(realm: Realm, roomId: String): RealmQuery = + TimelineEventEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true) + .sort("${TimelineEventEntityFields.ROOT.THREAD_SUMMARY_LATEST_MESSAGE}.${TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS}", Sort.DESCENDING) + +/** + * Map each root thread TimelineEvent with the equivalent decrypted text edition/replacement + */ +internal fun List.mapEventsWithEdition(realm: Realm, roomId: String): List = + this.map { + EventAnnotationsSummaryEntity + .where(realm, roomId, eventId = it.eventId) + .findFirst() + ?.editSummary + ?.editions + ?.lastOrNull() + ?.eventId + ?.let { editedEventId -> + TimelineEventEntity.where(realm, roomId, eventId = editedEventId).findFirst()?.let { editedEvent -> + it.root.threadDetails = it.root.threadDetails?.copy(lastRootThreadEdition = editedEvent.root?.asDomain()?.getDecryptedTextSummary() + ?: "(edited)") + it + } ?: it + } ?: it + } + +/** + * Returns a list of all the marked unread threads that exists for the specified room + * @param roomId The roomId that the user is currently in + */ +internal fun TimelineEventEntity.Companion.findAllLocalThreadNotificationsForRoomId(realm: Realm, roomId: String): RealmQuery = + TimelineEventEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(TimelineEventEntityFields.ROOT.IS_ROOT_THREAD, true) + .beginGroup() + .equalTo(TimelineEventEntityFields.ROOT.THREAD_NOTIFICATION_STATE_STR, ThreadNotificationState.NEW_MESSAGE.name) + .or() + .equalTo(TimelineEventEntityFields.ROOT.THREAD_NOTIFICATION_STATE_STR, ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE.name) + .endGroup() + +/** + * Returns whether or not the given user is participating in a current thread + * @param roomId the room that the thread exists + * @param rootThreadEventId the thread that the search will be done + * @param senderId the user that will try to find participation + */ +internal fun TimelineEventEntity.Companion.isUserParticipatingInThread(realm: Realm, roomId: String, rootThreadEventId: String, senderId: String): Boolean = + TimelineEventEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .equalTo(TimelineEventEntityFields.ROOT.SENDER, senderId) + .findFirst() + ?.let { true } + ?: false + +/** + * Returns whether or not the given user is mentioned in a current thread + * @param roomId the room that the thread exists + * @param rootThreadEventId the thread that the search will be done + * @param userId the user that will try to find if there is a mention + */ +internal fun TimelineEventEntity.Companion.isUserMentionedInThread(realm: Realm, roomId: String, rootThreadEventId: String, userId: String): Boolean = + TimelineEventEntity + .whereRoomId(realm, roomId = roomId) + .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .equalTo(TimelineEventEntityFields.ROOT.SENDER, userId) + .findAll() + .firstOrNull { isUserMentioned(userId, it) } + ?.let { true } + ?: false + +/** + * Find the read receipt for the current user + */ +internal fun findMyReadReceipt(realm: Realm, roomId: String, userId: String): String? = + ReadReceiptEntity.where(realm, roomId = roomId, userId = userId) + .findFirst() + ?.eventId + +/** + * Returns whether or not the user is mentioned in the event + */ +internal fun isUserMentioned(currentUserId: String, timelineEventEntity: TimelineEventEntity?): Boolean { + return timelineEventEntity?.root?.asDomain()?.isUserMentioned(currentUserId) == true +} + +/** + * Update badge notifications. Count the number of new thread events after the latest + * read receipt and aggregate. This function will find and notify new thread events + * that the user is either mentioned, or the user had participated in. + * Important: If the root thread event is not fetched notification will not work + * Important: It will work only with the latest chunk, while read marker will be changed + * immediately so we should not display wrong notifications + */ +internal fun updateNotificationsNew(roomId: String, realm: Realm, currentUserId: String) { + val readReceipt = findMyReadReceipt(realm, roomId, currentUserId) ?: return + + val readReceiptChunk = ChunkEntity + .findIncludingEvent(realm, readReceipt) ?: return + + val readReceiptChunkTimelineEvents = readReceiptChunk + .timelineEvents + .where() + .equalTo(TimelineEventEntityFields.ROOM_ID, roomId) + .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING) + .findAll() ?: return + + val readReceiptChunkPosition = readReceiptChunkTimelineEvents.indexOfFirst { it.eventId == readReceipt } + + if (readReceiptChunkPosition == -1) return + + if (readReceiptChunkPosition < readReceiptChunkTimelineEvents.lastIndex) { + // If the read receipt is found inside the chunk + + val threadEventsAfterReadReceipt = readReceiptChunkTimelineEvents + .slice(readReceiptChunkPosition..readReceiptChunkTimelineEvents.lastIndex) + .filter { it.root?.isThread() == true } + + // In order for the below code to work for old events, we should save the previous read receipt + // and then continue with the chunk search for that read receipt + /* + val newThreadEventsList = arrayListOf() + newThreadEventsList.addAll(threadEventsAfterReadReceipt) + + // got from latest chunk all new threads, lets move to the others + var nextChunk = ChunkEntity + .find(realm = realm, roomId = roomId, nextToken = readReceiptChunk.nextToken) + .takeIf { readReceiptChunk.nextToken != null } + while (nextChunk != null) { + newThreadEventsList.addAll(nextChunk.timelineEvents + .filter { it.root?.isThread() == true }) + nextChunk = ChunkEntity + .find(realm = realm, roomId = roomId, nextToken = nextChunk.nextToken) + .takeIf { readReceiptChunk.nextToken != null } + }*/ + + // Find if the user is mentioned in those events + val userMentionsList = threadEventsAfterReadReceipt + .filter { + isUserMentioned(currentUserId = currentUserId, it) + }.map { + it.root?.rootThreadEventId + } + + // Find the root events in the new thread events + val rootThreads = threadEventsAfterReadReceipt.distinctBy { it.root?.rootThreadEventId }.mapNotNull { it.root?.rootThreadEventId } + + // Update root thread events only if the user have participated in + rootThreads.forEach { eventId -> + val isUserParticipating = TimelineEventEntity.isUserParticipatingInThread( + realm = realm, + roomId = roomId, + rootThreadEventId = eventId, + senderId = currentUserId) + val rootThreadEventEntity = EventEntity.where(realm, eventId).findFirst() + + if (isUserParticipating) { + rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_MESSAGE + } + + if (userMentionsList.contains(eventId)) { + rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt new file mode 100644 index 0000000000..700b94a985 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/lightweight/LightweightSettingsStorage.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.database.lightweight + +import android.content.Context +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import javax.inject.Inject + +/** + * The purpose of this class is to provide an alternative and lightweight way to store settings/data + * on the sdi without using the database. This should be used just for sdk/user preferences and + * not for large data sets + */ + +class LightweightSettingsStorage @Inject constructor(context: Context) { + + private val sdkDefaultPrefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext) + + fun setThreadMessagesEnabled(enabled: Boolean) { + sdkDefaultPrefs.edit { + putBoolean(MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED, enabled) + } + } + + fun areThreadMessagesEnabled(): Boolean { + return sdkDefaultPrefs.getBoolean(MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED, false) + } + + companion object { + const val MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED = "MATRIX_SDK_SETTINGS_THREAD_MESSAGES_ENABLED" + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt index 613b38e340..9c420e81fd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventMapper.kt @@ -21,7 +21,11 @@ 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.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.session.threads.ThreadNotificationState 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 @@ -51,6 +55,10 @@ 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 + eventEntity.threadNotificationState = event.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE return eventEntity } @@ -93,6 +101,23 @@ internal object EventMapper { MXCryptoError.ErrorType.valueOf(errorCode) } it.mCryptoErrorReason = eventEntity.decryptionErrorReason + it.threadDetails = ThreadDetails( + isRootThread = eventEntity.isRootThread, + isThread = if (it.threadDetails?.isThread == true) true else eventEntity.isThread(), + numberOfThreads = eventEntity.numberOfThreads, + threadSummarySenderInfo = eventEntity.threadSummaryLatestMessage?.let { timelineEventEntity -> + SenderInfo( + userId = timelineEventEntity.root?.sender ?: "", + displayName = timelineEventEntity.senderName, + isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName, + avatarUrl = timelineEventEntity.senderAvatar + ) + }, + threadNotificationState = eventEntity.threadNotificationState, + threadSummaryLatestTextMessage = eventEntity.threadSummaryLatestMessage?.root?.asDomain()?.getDecryptedTextSummary(), + lastMessageTimestamp = eventEntity.threadSummaryLatestMessage?.root?.originServerTs + + ) } } } @@ -101,9 +126,15 @@ internal fun EventEntity.asDomain(castJsonNumbers: Boolean = false): Event { return EventMapper.map(this, castJsonNumbers) } -internal fun Event.toEntity(roomId: String, sendState: SendState, ageLocalTs: Long?): EventEntity { +internal fun Event.toEntity(roomId: String, sendState: SendState, ageLocalTs: Long?, contentToInject: String? = null): EventEntity { return EventMapper.map(this, roomId).apply { this.sendState = sendState this.ageLocalTs = ageLocalTs + contentToInject?.let { + this.content = it + if (this.type == EventType.STICKER) { + this.type = EventType.MESSAGE + } + } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt index ce2d1efc1d..445181e576 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventEntity.kt @@ -19,7 +19,7 @@ package org.matrix.android.sdk.internal.database.model import io.realm.RealmObject import io.realm.annotations.Index import org.matrix.android.sdk.api.session.room.send.SendState -import org.matrix.android.sdk.api.util.JsonDict +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState import org.matrix.android.sdk.internal.crypto.MXEventDecryptionResult import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.di.MoshiProvider @@ -40,7 +40,12 @@ internal open class EventEntity(@Index var eventId: String = "", var unsignedData: String? = null, var redacts: String? = null, var decryptionResultJson: String? = null, - var ageLocalTs: Long? = null + var ageLocalTs: Long? = null, + // Thread related, no need to create a new Entity for performance + @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 @@ -53,6 +58,15 @@ internal open class EventEntity(@Index var eventId: String = "", sendStateStr = value.name } + private var threadNotificationStateStr: String = ThreadNotificationState.NO_NEW_MESSAGE.name + var threadNotificationState: ThreadNotificationState + get() { + return ThreadNotificationState.valueOf(threadNotificationStateStr) + } + set(value) { + threadNotificationStateStr = value.name + } + var decryptionErrorCode: String? = null set(value) { if (value != field) field = value @@ -65,10 +79,10 @@ internal open class EventEntity(@Index var eventId: String = "", companion object - fun setDecryptionResult(result: MXEventDecryptionResult, clearEvent: JsonDict? = null) { + fun setDecryptionResult(result: MXEventDecryptionResult) { assertIsManaged() val decryptionResult = OlmDecryptionResult( - payload = clearEvent ?: result.clearEvent, + payload = result.clearEvent, senderKey = result.senderCurve25519Key, keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) }, forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain @@ -84,4 +98,6 @@ internal open class EventEntity(@Index var eventId: String = "", .findFirst() ?.canBeProcessed = true } + + fun isThread(): Boolean = rootThreadEventId != null } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventEntityQueries.kt index 240b2a0691..f7fa1037ba 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventEntityQueries.kt @@ -49,6 +49,11 @@ internal fun EventEntity.Companion.where(realm: Realm, eventId: String): RealmQu .equalTo(EventEntityFields.EVENT_ID, eventId) } +internal fun EventEntity.Companion.whereRoomId(realm: Realm, roomId: String): RealmQuery { + return realm.where() + .equalTo(EventEntityFields.ROOM_ID, roomId) +} + internal fun EventEntity.Companion.where(realm: Realm, eventIds: List): RealmQuery { return realm.where() .`in`(EventEntityFields.EVENT_ID, eventIds.toTypedArray()) @@ -85,3 +90,8 @@ internal fun RealmList.find(eventId: String): EventEntity? { internal fun RealmList.fastContains(eventId: String): Boolean { return this.find(eventId) != null } + +internal fun EventEntity.Companion.whereRootThreadEventId(realm: Realm, rootThreadEventId: String): RealmQuery { + return realm.where() + .equalTo(EventEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId) +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt index aa1ce41bb7..63f41ebf2c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt @@ -59,6 +59,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 +101,7 @@ internal fun RealmQuery.filterEvents(filters: TimelineEvent if (filters.filterRedacted) { not().like(TimelineEventEntityFields.ROOT.UNSIGNED_DATA, TimelineEventFilter.Unsigned.REDACTED) } + return this } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ThumbnailExtractor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ThumbnailExtractor.kt index 82cd682eae..55db64f309 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ThumbnailExtractor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/ThumbnailExtractor.kt @@ -66,7 +66,7 @@ internal class ThumbnailExtractor @Inject constructor( thumbnail.recycle() outputStream.reset() } ?: run { - Timber.e("Cannot extract video thumbnail at %s", attachment.queryUri.toString()) + Timber.e("Cannot extract video thumbnail at ${attachment.queryUri}") } } catch (e: Exception) { Timber.e(e, "Cannot extract video thumbnail") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt index 7047d38260..f498322967 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/RoomEventFilter.kt @@ -48,6 +48,16 @@ data class RoomEventFilter( * a wildcard to match any sequence of characters. */ @Json(name = "types") val types: List? = null, + /** + * A list of relation types which must be exist pointing to the event being filtered. + * If this list is absent then no filtering is done on relation types. + */ + @Json(name = "relation_types") val relationTypes: List? = null, + /** + * A list of senders of relations which must exist pointing to the event being filtered. + * If this list is absent then no filtering is done on relation types. + */ + @Json(name = "relation_senders") val relationSenders: List? = null, /** * A list of room IDs to include. If this list is absent then all rooms are included. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt index 1c3d1971c2..2d8c3e9c78 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/DefaultRoom.kt @@ -35,6 +35,7 @@ import org.matrix.android.sdk.api.session.room.send.DraftService import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.state.StateService import org.matrix.android.sdk.api.session.room.tags.TagsService +import org.matrix.android.sdk.api.session.room.threads.ThreadsService import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.typing.TypingService import org.matrix.android.sdk.api.session.room.uploads.UploadsService @@ -54,6 +55,7 @@ import java.security.InvalidParameterException internal class DefaultRoom(override val roomId: String, private val roomSummaryDataSource: RoomSummaryDataSource, private val timelineService: TimelineService, + private val threadsService: ThreadsService, private val sendService: SendService, private val draftService: DraftService, private val stateService: StateService, @@ -77,6 +79,7 @@ internal class DefaultRoom(override val roomId: String, ) : Room, TimelineService by timelineService, + ThreadsService by threadsService, SendService by sendService, DraftService by draftService, StateService by stateService, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt index 3cc08df0e8..acceaf6e24 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt @@ -44,6 +44,7 @@ import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.internal.SessionManager import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import org.matrix.android.sdk.internal.crypto.verification.toState +import org.matrix.android.sdk.internal.database.helper.findRootThreadEvent import org.matrix.android.sdk.internal.database.mapper.ContentMapper import org.matrix.android.sdk.internal.database.mapper.EventMapper import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity @@ -332,6 +333,29 @@ internal class EventRelationsAggregationProcessor @Inject constructor( ) } } + + if (!isLocalEcho) { + val replaceEvent = TimelineEventEntity.where(realm, roomId, eventId).findFirst() + handleThreadSummaryEdition(editedEvent, replaceEvent, existingSummary?.editions) + } + } + + /** + * Check if the edition is on the latest thread event, and update it accordingly + */ + private fun handleThreadSummaryEdition(editedEvent: EventEntity?, + replaceEvent: TimelineEventEntity?, + editions: List?) { + replaceEvent ?: return + editedEvent ?: return + editedEvent.findRootThreadEvent()?.apply { + val threadSummaryEventId = threadSummaryLatestMessage?.eventId + if (editedEvent.eventId == threadSummaryEventId || editions?.any { it.eventId == threadSummaryEventId } == true) { + // The edition is for the latest event or for any event replaced, this is to handle multiple + // edits of the same latest event + threadSummaryLatestMessage = replaceEvent + } + } } private fun handleResponse(realm: Realm, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt index efc5166a0c..399bfbd0e4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt @@ -226,7 +226,8 @@ internal interface RoomAPI { suspend fun getRelations(@Path("roomId") roomId: String, @Path("eventId") eventId: String, @Path("relationType") relationType: String, - @Path("eventType") eventType: String + @Path("eventType") eventType: String, + @Query("limit") limit: Int? = null ): RelationsResponse /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt index 4ab06338a2..70c1ab4f42 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomFactory.kt @@ -35,6 +35,7 @@ import org.matrix.android.sdk.internal.session.room.state.DefaultStateService import org.matrix.android.sdk.internal.session.room.state.SendStateTask import org.matrix.android.sdk.internal.session.room.summary.RoomSummaryDataSource import org.matrix.android.sdk.internal.session.room.tags.DefaultTagsService +import org.matrix.android.sdk.internal.session.room.threads.DefaultThreadsService import org.matrix.android.sdk.internal.session.room.timeline.DefaultTimelineService import org.matrix.android.sdk.internal.session.room.typing.DefaultTypingService import org.matrix.android.sdk.internal.session.room.uploads.DefaultUploadsService @@ -50,6 +51,7 @@ internal interface RoomFactory { internal class DefaultRoomFactory @Inject constructor(private val cryptoService: CryptoService, private val roomSummaryDataSource: RoomSummaryDataSource, private val timelineServiceFactory: DefaultTimelineService.Factory, + private val threadsServiceFactory: DefaultThreadsService.Factory, private val sendServiceFactory: DefaultSendService.Factory, private val draftServiceFactory: DefaultDraftService.Factory, private val stateServiceFactory: DefaultStateService.Factory, @@ -76,6 +78,7 @@ internal class DefaultRoomFactory @Inject constructor(private val cryptoService: roomId = roomId, roomSummaryDataSource = roomSummaryDataSource, timelineService = timelineServiceFactory.create(roomId), + threadsService = threadsServiceFactory.create(roomId), sendService = sendServiceFactory.create(roomId), draftService = draftServiceFactory.create(roomId), stateService = stateServiceFactory.create(roomId), diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt index 64f6bc0b30..f831a77a5d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt @@ -77,6 +77,8 @@ import org.matrix.android.sdk.internal.session.room.relation.DefaultUpdateQuickR import org.matrix.android.sdk.internal.session.room.relation.FetchEditHistoryTask import org.matrix.android.sdk.internal.session.room.relation.FindReactionEventForUndoTask import org.matrix.android.sdk.internal.session.room.relation.UpdateQuickReactionTask +import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadTimelineTask +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportContentTask import org.matrix.android.sdk.internal.session.room.reporting.ReportContentTask import org.matrix.android.sdk.internal.session.room.state.DefaultSendStateTask @@ -289,4 +291,7 @@ internal abstract class RoomModule { @Binds abstract fun bindGetRoomSummaryTask(task: DefaultGetRoomSummaryTask): GetRoomSummaryTask + + @Binds + abstract fun bindFetchThreadTimelineTask(task: DefaultFetchThreadTimelineTask): FetchThreadTimelineTask } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt index 5ae4007c63..ee52fe574b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt @@ -83,7 +83,9 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr // } val modified = unsignedData.copy(redactedEvent = redactionEvent) - eventToPrune.content = ContentMapper.map(emptyMap()) + // I Commented the line below, it should not be empty while we lose all the previous info about + // the redacted event +// eventToPrune.content = ContentMapper.map(emptyMap()) eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified) eventToPrune.decryptionResultJson = null eventToPrune.decryptionErrorCode = null diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt index cbcc108ddd..95e5771757 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt @@ -32,12 +32,15 @@ import org.matrix.android.sdk.api.util.NoOpCancellable import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider +import org.matrix.android.sdk.internal.crypto.DefaultCryptoService import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor import org.matrix.android.sdk.internal.task.TaskExecutor @@ -51,12 +54,15 @@ internal class DefaultRelationService @AssistedInject constructor( private val eventSenderProcessor: EventSenderProcessor, private val eventFactory: LocalEchoEventFactory, private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, + private val cryptoService: DefaultCryptoService, private val findReactionEventForUndoTask: FindReactionEventForUndoTask, private val fetchEditHistoryTask: FetchEditHistoryTask, + private val fetchThreadTimelineTask: FetchThreadTimelineTask, private val timelineEventMapper: TimelineEventMapper, + @UserId private val userId: String, @SessionDatabase private val monarchy: Monarchy, private val taskExecutor: TaskExecutor) : - RelationService { + RelationService { @AssistedFactory interface Factory { @@ -139,8 +145,20 @@ internal class DefaultRelationService @AssistedInject constructor( return fetchEditHistoryTask.execute(FetchEditHistoryTask.Params(roomId, eventId)) } - override fun replyToMessage(eventReplied: TimelineEvent, replyText: CharSequence, autoMarkdown: Boolean): Cancelable? { - val event = eventFactory.createReplyTextEvent(roomId, eventReplied, replyText, autoMarkdown) + override fun replyToMessage( + eventReplied: TimelineEvent, + replyText: CharSequence, + autoMarkdown: Boolean, + showInThread: Boolean, + rootThreadEventId: String? + ): Cancelable? { + val event = eventFactory.createReplyTextEvent( + roomId = roomId, + eventReplied = eventReplied, + replyText = replyText, + autoMarkdown = autoMarkdown, + rootThreadEventId = rootThreadEventId, + showInThread = showInThread) ?.also { saveLocalEcho(it) } ?: return null @@ -166,6 +184,47 @@ internal class DefaultRelationService @AssistedInject constructor( } } + override fun replyInThread( + rootThreadEventId: String, + replyInThreadText: CharSequence, + msgType: String, + autoMarkdown: Boolean, + formattedText: String?, + eventReplied: TimelineEvent?): Cancelable? { + val event = if (eventReplied != null) { + // Reply within a thread + eventFactory.createReplyTextEvent( + roomId = roomId, + eventReplied = eventReplied, + replyText = replyInThreadText, + autoMarkdown = autoMarkdown, + rootThreadEventId = rootThreadEventId, + showInThread = false + ) + ?.also { + saveLocalEcho(it) + } + ?: return null + } else { + // Normal thread reply + eventFactory.createThreadTextEvent( + rootThreadEventId = rootThreadEventId, + roomId = roomId, + text = replyInThreadText, + msgType = msgType, + autoMarkdown = autoMarkdown, + formattedText = formattedText) + .also { + saveLocalEcho(it) + } + } + return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId)) + } + + override suspend fun fetchThreadTimeline(rootThreadEventId: String): Boolean { + return fetchThreadTimelineTask.execute(FetchThreadTimelineTask.Params(roomId, rootThreadEventId)) + } + /** * Saves the event in database as a local echo. * SendState is set to UNSENT and it's added to a the sendingTimelineEvents list of the room. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt index a40a8df443..4551f390e7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt @@ -97,7 +97,13 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor: val roomId = replyToEdit.roomId if (replyToEdit.root.sendState.hasFailed()) { // We create a new in memory event for the EventSenderProcessor but we keep the eventId of the failed event. - val editedEvent = eventFactory.createReplyTextEvent(roomId, originalTimelineEvent, newBodyText, false)?.copy( + val editedEvent = eventFactory.createReplyTextEvent( + roomId = roomId, + eventReplied = originalTimelineEvent, + replyText = newBodyText, + autoMarkdown = false, + showInThread = false + )?.copy( eventId = replyToEdit.eventId ) ?: return NoOpCancellable updateFailedEchoWithEvent(roomId, replyToEdit.eventId, editedEvent) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt new file mode 100644 index 0000000000..e0d501c515 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt @@ -0,0 +1,207 @@ +/* + * 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.session.room.relation.threads + +import com.zhuinden.monarchy.Monarchy +import io.realm.Realm +import org.matrix.android.sdk.api.session.crypto.MXCryptoError +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.room.model.RoomMemberContent +import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.internal.crypto.CryptoSessionInfoProvider +import org.matrix.android.sdk.internal.crypto.DefaultCryptoService +import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult +import org.matrix.android.sdk.internal.database.helper.addTimelineEvent +import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded +import org.matrix.android.sdk.internal.database.mapper.asDomain +import org.matrix.android.sdk.internal.database.mapper.toEntity +import org.matrix.android.sdk.internal.database.model.ChunkEntity +import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity +import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.EventInsertType +import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryEntity +import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore +import org.matrix.android.sdk.internal.database.query.findLastForwardChunkOfRoom +import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.database.query.getOrNull +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.network.GlobalErrorReceiver +import org.matrix.android.sdk.internal.network.executeRequest +import org.matrix.android.sdk.internal.session.events.getFixedRoomMemberContent +import org.matrix.android.sdk.internal.session.room.RoomAPI +import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection +import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.awaitTransaction +import timber.log.Timber +import javax.inject.Inject + +internal interface FetchThreadTimelineTask : Task { + data class Params( + val roomId: String, + val rootThreadEventId: String + ) +} + +internal class DefaultFetchThreadTimelineTask @Inject constructor( + private val roomAPI: RoomAPI, + private val globalErrorReceiver: GlobalErrorReceiver, + private val cryptoSessionInfoProvider: CryptoSessionInfoProvider, + @SessionDatabase private val monarchy: Monarchy, + @UserId private val userId: String, + private val cryptoService: DefaultCryptoService +) : FetchThreadTimelineTask { + + override suspend fun execute(params: FetchThreadTimelineTask.Params): Boolean { + val isRoomEncrypted = cryptoSessionInfoProvider.isRoomEncrypted(params.roomId) + val response = executeRequest(globalErrorReceiver) { + roomAPI.getRelations( + roomId = params.roomId, + eventId = params.rootThreadEventId, + relationType = RelationType.IO_THREAD, + eventType = if (isRoomEncrypted) EventType.ENCRYPTED else EventType.MESSAGE, + limit = 2000 + ) + } + + val threadList = response.chunks + listOfNotNull(response.originalEvent) + + return storeNewEventsIfNeeded(threadList, params.roomId) + } + + /** + * Store new events if they are not already received, and returns weather or not, + * a timeline update should be made + * @param threadList is the list containing the thread replies + * @param roomId the roomId of the the thread + * @return + */ + private suspend fun storeNewEventsIfNeeded(threadList: List, roomId: String): Boolean { + var eventsSkipped = 0 + monarchy + .awaitTransaction { realm -> + val chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId) + + val optimizedThreadSummaryMap = hashMapOf() + val roomMemberContentsByUser = HashMap() + + for (event in threadList.reversed()) { + if (event.eventId == null || event.senderId == null || event.type == null) { + eventsSkipped++ + continue + } + + if (EventEntity.where(realm, event.eventId).findFirst() != null) { + // Skip if event already exists + eventsSkipped++ + continue + } + if (event.isEncrypted()) { + // Decrypt events that will be stored + decryptIfNeeded(event, roomId) + } + + handleReaction(realm, event, roomId) + + val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it } + val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.INCREMENTAL_SYNC) + + // Sender info + roomMemberContentsByUser.getOrPut(event.senderId) { + // If we don't have any new state on this user, get it from db + val rootStateEvent = CurrentStateEventEntity.getOrNull(realm, roomId, event.senderId, EventType.STATE_ROOM_MEMBER)?.root + rootStateEvent?.asDomain()?.getFixedRoomMemberContent() + } + + chunk?.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser) + eventEntity.rootThreadEventId?.let { + // This is a thread event + optimizedThreadSummaryMap[it] = eventEntity + } ?: run { + // This is a normal event or a root thread one + optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity + } + } + + optimizedThreadSummaryMap.updateThreadSummaryIfNeeded( + roomId = roomId, + realm = realm, + currentUserId = userId, + shouldUpdateNotifications = false + ) + } + Timber.i("----> size: ${threadList.size} | skipped: $eventsSkipped | threads: ${threadList.map { it.eventId }}") + + return eventsSkipped == threadList.size + } + + /** + * Invoke the event decryption mechanism for a specific event + */ + + private fun decryptIfNeeded(event: Event, roomId: String) { + try { + // Event from sync does not have roomId, so add it to the event first + val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "") + event.mxDecryptionResult = OlmDecryptionResult( + payload = result.clearEvent, + senderKey = result.senderCurve25519Key, + keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) }, + forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain + ) + } catch (e: MXCryptoError) { + if (e is MXCryptoError.Base) { + event.mCryptoError = e.errorType + event.mCryptoErrorReason = e.technicalMessage.takeIf { it.isNotEmpty() } ?: e.detailedErrorDescription + } + } + } + + private fun handleReaction(realm: Realm, + event: Event, + roomId: String) { + val unsignedData = event.unsignedData ?: return + val relatedEventId = event.eventId ?: return + + unsignedData.relations?.annotations?.chunk?.forEach { relationChunk -> + + if (relationChunk.type == EventType.REACTION) { + val reaction = relationChunk.key + Timber.i("----> Annotation found in ${event.eventId} ${relationChunk.key} ") + + val eventSummary = EventAnnotationsSummaryEntity.getOrCreate(realm, roomId, relatedEventId) + var sum = eventSummary.reactionsSummary.find { it.key == reaction } + + if (sum == null) { + sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java) + sum.key = reaction + sum.firstTimestamp = event.originServerTs ?: 0 + Timber.v("Adding synced reaction $reaction") + sum.count = 1 + // reactionEventId not included in the /relations API +// sum.sourceEvents.add(reactionEventId) + eventSummary.reactionsSummary.add(sum) + } else { + sum.count += 1 + } + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt index 5662a72cb8..8c0ea0ec4c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt @@ -98,8 +98,14 @@ internal class DefaultSendService @AssistedInject constructor( .let { sendEvent(it) } } - override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean): Cancelable { - return localEchoEventFactory.createQuotedTextEvent(roomId, quotedEvent, text, autoMarkdown) + override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String?): Cancelable { + return localEchoEventFactory.createQuotedTextEvent( + roomId = roomId, + quotedEvent = quotedEvent, + text = text, + autoMarkdown = autoMarkdown, + rootThreadEventId = rootThreadEventId + ) .also { createLocalEcho(it) } .let { sendEvent(it) } } @@ -254,22 +260,37 @@ internal class DefaultSendService @AssistedInject constructor( override fun sendMedias(attachments: List, compressBeforeSending: Boolean, - roomIds: Set): Cancelable { + roomIds: Set, + rootThreadEventId: String? + ): Cancelable { return attachments.mapTo(CancelableBag()) { - sendMedia(it, compressBeforeSending, roomIds) + sendMedia( + attachment = it, + compressBeforeSending = compressBeforeSending, + roomIds = roomIds, + rootThreadEventId = rootThreadEventId) } } override fun sendMedia(attachment: ContentAttachmentData, compressBeforeSending: Boolean, - roomIds: Set): Cancelable { + roomIds: Set, + rootThreadEventId: String? + ): Cancelable { + // Ensure that the event will not be send in a thread if we are a different flow. + // Like sending files to multiple rooms + val rootThreadId = if (roomIds.isNotEmpty()) null else rootThreadEventId + // Create an event with the media file path // Ensure current roomId is included in the set val allRoomIds = (roomIds + roomId).toList() // Create local echo for each room val allLocalEchoes = allRoomIds.map { - localEchoEventFactory.createMediaEvent(it, attachment).also { event -> + localEchoEventFactory.createMediaEvent( + roomId = it, + attachment = attachment, + rootThreadEventId = rootThreadId).also { event -> createLocalEcho(event) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index 1e46602411..3c36d58710 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt @@ -28,6 +28,7 @@ import org.matrix.android.sdk.api.session.events.model.LocalEcho import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.UnsignedData 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.message.AudioInfo import org.matrix.android.sdk.api.session.room.model.message.AudioWaveformInfo import org.matrix.android.sdk.api.session.room.model.message.FileInfo @@ -45,6 +46,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent +import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent @@ -292,13 +294,16 @@ internal class LocalEchoEventFactory @Inject constructor( )) } - fun createMediaEvent(roomId: String, attachment: ContentAttachmentData): Event { + fun createMediaEvent(roomId: String, + attachment: ContentAttachmentData, + rootThreadEventId: String? + ): Event { return when (attachment.type) { - ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment) - ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment) - ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment, isVoiceMessage = false) - ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent(roomId, attachment, isVoiceMessage = true) - ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment) + ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment, rootThreadEventId) + ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment, rootThreadEventId) + ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment, isVoiceMessage = false, rootThreadEventId = rootThreadEventId) + ContentAttachmentData.Type.VOICE_MESSAGE -> createAudioEvent(roomId, attachment, isVoiceMessage = true, rootThreadEventId = rootThreadEventId) + ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment, rootThreadEventId) } } @@ -321,7 +326,7 @@ internal class LocalEchoEventFactory @Inject constructor( unsignedData = UnsignedData(age = null, transactionId = localId)) } - private fun createImageEvent(roomId: String, attachment: ContentAttachmentData): Event { + private fun createImageEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event { var width = attachment.width var height = attachment.height @@ -345,12 +350,19 @@ internal class LocalEchoEventFactory @Inject constructor( height = height?.toInt() ?: 0, size = attachment.size ), - url = attachment.queryUri.toString() + url = attachment.queryUri.toString(), + relatesTo = rootThreadEventId?.let { + RelationDefaultContent( + type = RelationType.IO_THREAD, + eventId = it, + inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) + ) + } ) return createMessageEvent(roomId, content) } - private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData): Event { + private fun createVideoEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event { val mediaDataRetriever = MediaMetadataRetriever() mediaDataRetriever.setDataSource(context, attachment.queryUri) @@ -381,12 +393,23 @@ internal class LocalEchoEventFactory @Inject constructor( thumbnailUrl = attachment.queryUri.toString(), thumbnailInfo = thumbnailInfo ), - url = attachment.queryUri.toString() + url = attachment.queryUri.toString(), + relatesTo = rootThreadEventId?.let { + RelationDefaultContent( + type = RelationType.IO_THREAD, + eventId = it, + inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) + ) + } ) return createMessageEvent(roomId, content) } - private fun createAudioEvent(roomId: String, attachment: ContentAttachmentData, isVoiceMessage: Boolean): Event { + private fun createAudioEvent(roomId: String, + attachment: ContentAttachmentData, + isVoiceMessage: Boolean, + rootThreadEventId: String? + ): Event { val content = MessageAudioContent( msgType = MessageType.MSGTYPE_AUDIO, body = attachment.name ?: "audio", @@ -400,12 +423,19 @@ internal class LocalEchoEventFactory @Inject constructor( duration = attachment.duration?.toInt(), waveform = waveformSanitizer.sanitize(attachment.waveform) ), - voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap() + voiceMessageIndicator = if (!isVoiceMessage) null else emptyMap(), + relatesTo = rootThreadEventId?.let { + RelationDefaultContent( + type = RelationType.IO_THREAD, + eventId = it, + inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) + ) + } ) return createMessageEvent(roomId, content) } - private fun createFileEvent(roomId: String, attachment: ContentAttachmentData): Event { + private fun createFileEvent(roomId: String, attachment: ContentAttachmentData, rootThreadEventId: String?): Event { val content = MessageFileContent( msgType = MessageType.MSGTYPE_FILE, body = attachment.name ?: "file", @@ -413,7 +443,14 @@ internal class LocalEchoEventFactory @Inject constructor( mimeType = attachment.getSafeMimeType()?.takeIf { it.isNotBlank() }, size = attachment.size ), - url = attachment.queryUri.toString() + url = attachment.queryUri.toString(), + relatesTo = rootThreadEventId?.let { + RelationDefaultContent( + type = RelationType.IO_THREAD, + eventId = it, + inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(it)) + ) + } ) return createMessageEvent(roomId, content) } @@ -423,6 +460,7 @@ internal class LocalEchoEventFactory @Inject constructor( } fun createEvent(roomId: String, type: String, content: Content?): Event { + val newContent = enhanceStickerIfNeeded(type, content) ?: content val localId = LocalEcho.createLocalEchoId() return Event( roomId = roomId, @@ -430,19 +468,65 @@ internal class LocalEchoEventFactory @Inject constructor( senderId = userId, eventId = localId, type = type, - content = content, + content = newContent, unsignedData = UnsignedData(age = null, transactionId = localId) ) } + /** + * Enhance sticker to support threads fallback if needed + */ + private fun enhanceStickerIfNeeded(type: String, content: Content?): Content? { + var newContent: Content? = null + if (type == EventType.STICKER) { + val isThread = (content.toModel())?.relatesTo?.type == RelationType.IO_THREAD + val rootThreadEventId = (content.toModel())?.relatesTo?.eventId + if (isThread && rootThreadEventId != null) { + val newRelationalDefaultContent = (content.toModel())?.relatesTo?.copy( + inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId)) + ) + newContent = (content.toModel())?.copy( + relatesTo = newRelationalDefaultContent + ).toContent() + } + } + return newContent + } + + /** + * Creates a thread event related to the already existing root event + */ + fun createThreadTextEvent( + rootThreadEventId: String, + roomId: String, + text: CharSequence, + msgType: String, + autoMarkdown: Boolean, + formattedText: String?): Event { + val content = formattedText?.let { TextContent(text.toString(), it) } ?: createTextContent(text, autoMarkdown) + return createEvent( + roomId, + EventType.MESSAGE, + content.toThreadTextContent( + rootThreadEventId = rootThreadEventId, + latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId), + msgType = msgType) + .toContent()) + } + private fun dummyOriginServerTs(): Long { return System.currentTimeMillis() } + /** + * Creates a reply to a regular timeline Event or a thread Event if needed + */ fun createReplyTextEvent(roomId: String, eventReplied: TimelineEvent, replyText: CharSequence, - autoMarkdown: Boolean): Event? { + autoMarkdown: Boolean, + rootThreadEventId: String? = null, + showInThread: Boolean): Event? { // Fallbacks and event representation // TODO Add error/warning logs when any of this is null val permalink = permalinkFactory.createPermalink(eventReplied.root, false) ?: return null @@ -473,11 +557,33 @@ internal class LocalEchoEventFactory @Inject constructor( format = MessageFormat.FORMAT_MATRIX_HTML, body = replyFallback, formattedBody = replyFormatted, - relatesTo = RelationDefaultContent(null, null, ReplyToContent(eventId)) - ) + relatesTo = generateReplyRelationContent( + eventId = eventId, + rootThreadEventId = rootThreadEventId, + showAsReply = showInThread)) return createMessageEvent(roomId, content) } + /** + * Generates the appropriate relatesTo object for a reply event. + * It can either be a regular reply or a reply within a thread + * "m.relates_to": { + * "rel_type": "m.thread", + * "event_id": "$thread_root", + * "m.in_reply_to": { + * "event_id": "$event_target", + * "render_in": ["m.thread"] + * } + * } + */ + private fun generateReplyRelationContent(eventId: String, rootThreadEventId: String? = null, showAsReply: Boolean): RelationDefaultContent = + rootThreadEventId?.let { + RelationDefaultContent( + type = RelationType.IO_THREAD, + eventId = it, + inReplyTo = ReplyToContent(eventId = eventId, renderIn = if (showAsReply) arrayListOf("m.thread") else null)) + } ?: RelationDefaultContent(null, null, ReplyToContent(eventId = eventId)) + private fun buildFormattedReply(permalink: String, userLink: String, userId: String, bodyFormatted: String, newBodyFormatted: String): String { return REPLY_PATTERN.format( permalink, @@ -488,6 +594,7 @@ internal class LocalEchoEventFactory @Inject constructor( newBodyFormatted ) } + private fun buildReplyFallback(body: TextContent, originalSenderId: String?, newBodyText: String): String { return buildString { append("> <") @@ -593,11 +700,28 @@ internal class LocalEchoEventFactory @Inject constructor( quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, + rootThreadEventId: String? ): Event { val messageContent = quotedEvent.getLastMessageContent() val textMsg = messageContent?.body val quoteText = legacyRiotQuoteText(textMsg, text) - return createFormattedTextEvent(roomId, markdownParser.parse(quoteText, force = true, advanced = autoMarkdown), MessageType.MSGTYPE_TEXT) + + return if (rootThreadEventId != null) { + createMessageEvent( + roomId, + markdownParser + .parse(quoteText, force = true, advanced = autoMarkdown) + .toThreadTextContent( + rootThreadEventId = rootThreadEventId, + latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId), + msgType = MessageType.MSGTYPE_TEXT) + ) + } else { + createFormattedTextEvent( + roomId, + markdownParser.parse(quoteText, force = true, advanced = autoMarkdown), + MessageType.MSGTYPE_TEXT) + } } private fun legacyRiotQuoteText(quotedText: String?, myText: String): String { @@ -631,6 +755,7 @@ internal class LocalEchoEventFactory @Inject constructor( // // No whitespace because currently breaks temporary formatted text to Span const val REPLY_PATTERN = """
In reply to %s
%s
%s""" + const val QUOTE_PATTERN = """

%s

%s

""" // This is used to replace inner mx-reply tags val MX_REPLY_REGEX = ".*".toRegex() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt index 13095fbd58..1b1a66a1c4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt @@ -138,7 +138,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private } } - fun deleteFailedEchoAsync(roomId: String, eventId: String?) { + fun deleteFailedEchoAsync(roomId: String, eventId: String?) { monarchy.runTransactionSync { realm -> TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId ?: "").findFirst()?.deleteFromRealm() EventEntity.where(realm, eventId = eventId ?: "").findFirst()?.deleteFromRealm() @@ -215,4 +215,13 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private } } } + + /** + * Returns the latest known thread event message, or the rootThreadEventId if no other event found + */ + fun getLatestThreadEvent(rootThreadEventId: String): String { + return realmSessionProvider.withRealm { realm -> + EventEntity.where(realm, eventId = rootThreadEventId).findFirst()?.threadSummaryLatestMessage?.eventId + } ?: rootThreadEventId + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt index efc0b55abf..5c629f87f0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/TextContent.kt @@ -16,9 +16,12 @@ package org.matrix.android.sdk.internal.session.room.send +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.RelationDefaultContent +import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromHtmlReply import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromReply @@ -41,6 +44,29 @@ fun TextContent.toMessageTextContent(msgType: String = MessageType.MSGTYPE_TEXT) ) } +/** + * Transform a TextContent to a thread message content. It will also add the inReplyTo + * latestThreadEventId in order for the clients without threads enabled to render it appropriately + * If latest event not found, we pass rootThreadEventId + */ +fun TextContent.toThreadTextContent( + rootThreadEventId: String, + latestThreadEventId: String, + msgType: String = MessageType.MSGTYPE_TEXT): MessageTextContent { + return MessageTextContent( + msgType = msgType, + format = MessageFormat.FORMAT_MATRIX_HTML.takeIf { formattedText != null }, + body = text, + relatesTo = RelationDefaultContent( + type = RelationType.IO_THREAD, + eventId = rootThreadEventId, + inReplyTo = ReplyToContent( + eventId = latestThreadEventId + )), + formattedBody = formattedText + ) +} + fun TextContent.removeInReplyFallbacks(): TextContent { return copy( text = extractUsefulTextFromReply(this.text), diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt new file mode 100644 index 0000000000..5967ae8d2e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.internal.session.room.threads + +import androidx.lifecycle.LiveData +import com.zhuinden.monarchy.Monarchy +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import io.realm.Realm +import org.matrix.android.sdk.api.session.room.threads.ThreadsService +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.internal.database.helper.findAllLocalThreadNotificationsForRoomId +import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId +import org.matrix.android.sdk.internal.database.helper.isUserParticipatingInThread +import org.matrix.android.sdk.internal.database.helper.mapEventsWithEdition +import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.util.awaitTransaction + +internal class DefaultThreadsService @AssistedInject constructor( + @Assisted private val roomId: String, + @UserId private val userId: String, + @SessionDatabase private val monarchy: Monarchy, + private val timelineEventMapper: TimelineEventMapper, +) : ThreadsService { + + @AssistedFactory + interface Factory { + fun create(roomId: String): DefaultThreadsService + } + + override fun getMarkedThreadNotificationsLive(): LiveData> { + return monarchy.findAllMappedWithChanges( + { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun getMarkedThreadNotifications(): List { + return monarchy.fetchAllMappedSync( + { TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun getAllThreadsLive(): LiveData> { + return monarchy.findAllMappedWithChanges( + { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun getAllThreads(): List { + return monarchy.fetchAllMappedSync( + { TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) }, + { timelineEventMapper.map(it) } + ) + } + + override fun isUserParticipatingInThread(rootThreadEventId: String): Boolean { + return Realm.getInstance(monarchy.realmConfiguration).use { + TimelineEventEntity.isUserParticipatingInThread( + realm = it, + roomId = roomId, + rootThreadEventId = rootThreadEventId, + senderId = userId) + } + } + + override fun mapEventsWithEdition(threads: List): List { + return Realm.getInstance(monarchy.realmConfiguration).use { + threads.mapEventsWithEdition(it, roomId) + } + } + + override suspend fun markThreadAsRead(rootThreadEventId: String) { + monarchy.awaitTransaction { + EventEntity.where( + realm = it, + eventId = rootThreadEventId).findFirst()?.threadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt index 71823cd458..3dd4225b2c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt @@ -35,6 +35,7 @@ import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler @@ -60,6 +61,7 @@ internal class DefaultTimeline(private val roomId: String, timelineEventMapper: TimelineEventMapper, timelineInput: TimelineInput, threadsAwarenessHandler: ThreadsAwarenessHandler, + lightweightSettingsStorage: LightweightSettingsStorage, eventDecryptor: TimelineEventDecryptor) : Timeline { companion object { @@ -79,6 +81,9 @@ internal class DefaultTimeline(private val roomId: String, private val sequencer = SemaphoreCoroutineSequencer() private val postSnapshotSignalFlow = MutableSharedFlow(0) + private var isFromThreadTimeline = false + private var rootThreadEventId: String? = null + private val strategyDependencies = LoadTimelineStrategy.Dependencies( timelineSettings = settings, realm = backgroundRealm, @@ -89,6 +94,7 @@ internal class DefaultTimeline(private val roomId: String, timelineInput = timelineInput, timelineEventMapper = timelineEventMapper, threadsAwarenessHandler = threadsAwarenessHandler, + lightweightSettingsStorage = lightweightSettingsStorage, onEventsUpdated = this::sendSignalToPostSnapshot, onLimitedTimeline = this::onLimitedTimeline, onNewTimelineEvents = this::onNewTimelineEvents @@ -118,18 +124,21 @@ internal class DefaultTimeline(private val roomId: String, listeners.clear() } - override fun start() { + override fun start(rootThreadEventId: String?) { timelineScope.launch { loadRoomMembersIfNeeded() } timelineScope.launch { sequencer.post { if (isStarted.compareAndSet(false, true)) { + isFromThreadTimeline = rootThreadEventId != null + this@DefaultTimeline.rootThreadEventId = rootThreadEventId + // / val realm = Realm.getInstance(realmConfiguration) ensureReadReceiptAreLoaded(realm) backgroundRealm.set(realm) listenToPostSnapshotSignals() - openAround(initialEventId) + openAround(initialEventId, rootThreadEventId) postSnapshot() } } @@ -150,7 +159,7 @@ internal class DefaultTimeline(private val roomId: String, override fun restartWithEventId(eventId: String?) { timelineScope.launch { - openAround(eventId) + openAround(eventId, rootThreadEventId) postSnapshot() } } @@ -219,19 +228,24 @@ internal class DefaultTimeline(private val roomId: String, return true } - private suspend fun openAround(eventId: String?) = withContext(timelineDispatcher) { + private suspend fun openAround(eventId: String?, rootThreadEventId: String?) = withContext(timelineDispatcher) { val baseLogMessage = "openAround(eventId: $eventId)" Timber.v("$baseLogMessage started") if (!isStarted.get()) { throw IllegalStateException("You should call start before using timeline") } strategy.onStop() - strategy = if (eventId == null) { - buildStrategy(LoadTimelineStrategy.Mode.Live) - } else { - buildStrategy(LoadTimelineStrategy.Mode.Permalink(eventId)) + + strategy = when { + rootThreadEventId != null -> buildStrategy(LoadTimelineStrategy.Mode.Thread(rootThreadEventId)) + eventId == null -> buildStrategy(LoadTimelineStrategy.Mode.Live) + else -> buildStrategy(LoadTimelineStrategy.Mode.Permalink(eventId)) } - initPaginationStates(eventId) + + rootThreadEventId?.let { + initPaginationStates(null) + } ?: initPaginationStates(eventId) + strategy.onStart() loadMore( count = strategyDependencies.timelineSettings.initialSize, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt index 126374b430..d7d61f0b47 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimelineService.kt @@ -32,11 +32,13 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineService import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.internal.database.RealmSessionProvider +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.model.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.di.SessionDatabase +import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.session.room.membership.LoadRoomMembersTask import org.matrix.android.sdk.internal.session.sync.handler.room.ReadReceiptHandler import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler @@ -44,6 +46,7 @@ import org.matrix.android.sdk.internal.task.TaskExecutor internal class DefaultTimelineService @AssistedInject constructor( @Assisted private val roomId: String, + @UserId private val userId: String, @SessionDatabase private val monarchy: Monarchy, private val realmSessionProvider: RealmSessionProvider, private val timelineInput: TimelineInput, @@ -55,6 +58,7 @@ internal class DefaultTimelineService @AssistedInject constructor( private val timelineEventMapper: TimelineEventMapper, private val loadRoomMembersTask: LoadRoomMembersTask, private val threadsAwarenessHandler: ThreadsAwarenessHandler, + private val lightweightSettingsStorage: LightweightSettingsStorage, private val readReceiptHandler: ReadReceiptHandler, private val coroutineDispatchers: MatrixCoroutineDispatchers ) : TimelineService { @@ -79,7 +83,8 @@ internal class DefaultTimelineService @AssistedInject constructor( loadRoomMembersTask = loadRoomMembersTask, readReceiptHandler = readReceiptHandler, getEventTask = contextOfEventTask, - threadsAwarenessHandler = threadsAwarenessHandler + threadsAwarenessHandler = threadsAwarenessHandler, + lightweightSettingsStorage = lightweightSettingsStorage ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt index 528b564e8b..f332c4a35f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/LoadTimelineStrategy.kt @@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.ChunkEntityFields @@ -51,6 +52,7 @@ internal class LoadTimelineStrategy( sealed interface Mode { object Live : Mode data class Permalink(val originEventId: String) : Mode + data class Thread(val rootThreadEventId: String) : Mode fun originEventId(): String? { return if (this is Permalink) { @@ -59,6 +61,14 @@ internal class LoadTimelineStrategy( null } } + +// fun getRootThreadEventId(): String? { +// return if (this is Thread) { +// rootThreadEventId +// } else { +// null +// } +// } } data class Dependencies( @@ -71,6 +81,7 @@ internal class LoadTimelineStrategy( val timelineInput: TimelineInput, val timelineEventMapper: TimelineEventMapper, val threadsAwarenessHandler: ThreadsAwarenessHandler, + val lightweightSettingsStorage: LightweightSettingsStorage, val onEventsUpdated: (Boolean) -> Unit, val onLimitedTimeline: () -> Unit, val onNewTimelineEvents: (List) -> Unit @@ -198,12 +209,20 @@ internal class LoadTimelineStrategy( } private fun getChunkEntity(realm: Realm): RealmResults { - return if (mode is Mode.Permalink) { - ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId)) - } else { - ChunkEntity.where(realm, roomId) - .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) - .findAll() + return when (mode) { + is Mode.Live -> { + ChunkEntity.where(realm, roomId) + .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) + .findAll() + } + is Mode.Permalink -> { + ChunkEntity.findAllIncludingEvents(realm, listOf(mode.originEventId)) + } + is Mode.Thread -> { + ChunkEntity.where(realm, roomId) + .equalTo(ChunkEntityFields.IS_LAST_FORWARD, true) + .findAll() + } } } @@ -224,6 +243,7 @@ internal class LoadTimelineStrategy( timelineEventMapper = dependencies.timelineEventMapper, uiEchoManager = uiEchoManager, threadsAwarenessHandler = dependencies.threadsAwarenessHandler, + lightweightSettingsStorage = dependencies.lightweightSettingsStorage, initialEventId = mode.originEventId(), onBuiltEvents = dependencies.onEventsUpdated ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt index 6af03a858a..8507b63d1f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineChunk.kt @@ -29,6 +29,7 @@ import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.EventMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper import org.matrix.android.sdk.internal.database.model.ChunkEntity @@ -55,6 +56,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, private val timelineEventMapper: TimelineEventMapper, private val uiEchoManager: UIEchoManager? = null, private val threadsAwarenessHandler: ThreadsAwarenessHandler, + private val lightweightSettingsStorage: LightweightSettingsStorage, private val initialEventId: String?, private val onBuiltEvents: (Boolean) -> Unit) { @@ -92,7 +94,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, handleDatabaseChangeSet(frozenResults, changeSet) } - private var timelineEventEntities: RealmResults = chunkEntity.sortedTimelineEvents() + private var timelineEventEntities: RealmResults = chunkEntity.sortedTimelineEvents(timelineSettings.rootThreadEventId) private val builtEvents: MutableList = Collections.synchronizedList(ArrayList()) private val builtEventsIndexes: MutableMap = Collections.synchronizedMap(HashMap()) @@ -137,13 +139,18 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, } else if (direction == Timeline.Direction.BACKWARDS && prevChunk != null) { return prevChunk?.loadMore(count, direction, fetchOnServerIfNeeded) ?: LoadMoreResult.FAILURE } - val loadFromStorageCount = loadFromStorage(count, direction) - Timber.v("Has loaded $loadFromStorageCount items from storage in $direction") - val offsetCount = count - loadFromStorageCount + val loadFromStorage = loadFromStorage(count, direction).also { + logLoadedFromStorage(it, direction) + } + + val offsetCount = count - loadFromStorage.numberOfEvents + return if (direction == Timeline.Direction.FORWARDS && isLastForward.get()) { LoadMoreResult.REACHED_END } else if (direction == Timeline.Direction.BACKWARDS && isLastBackward.get()) { LoadMoreResult.REACHED_END + } else if (timelineSettings.isThreadTimeline() && loadFromStorage.threadReachedEnd) { + LoadMoreResult.REACHED_END } else if (offsetCount == 0) { LoadMoreResult.SUCCESS } else { @@ -187,6 +194,16 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, } } + /** + * Simple log that displays the number and timeline of loaded events + */ + private fun logLoadedFromStorage(loadedFromStorage: LoadedFromStorage, direction: Timeline.Direction) { + Timber.v("[" + + "${if (timelineSettings.isThreadTimeline()) "ThreadTimeLine" else "Timeline"}] Has loaded " + + "${loadedFromStorage.numberOfEvents} items from storage in $direction " + + if (timelineSettings.isThreadTimeline() && loadedFromStorage.threadReachedEnd) "[Reached End]" else "") + } + fun getBuiltEventIndex(eventId: String, searchInNext: Boolean, searchInPrev: Boolean): Int? { val builtEventIndex = builtEventsIndexes[eventId] if (builtEventIndex != null) { @@ -267,13 +284,23 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, /** * This method tries to read events from the current chunk. + * @return the number of events loaded. If we are in a thread timeline it also returns + * whether or not we reached the end/root message */ - private suspend fun loadFromStorage(count: Int, direction: Timeline.Direction): Int { - val displayIndex = getNextDisplayIndex(direction) ?: return 0 + private suspend fun loadFromStorage(count: Int, direction: Timeline.Direction): LoadedFromStorage { + val displayIndex = getNextDisplayIndex(direction) ?: return LoadedFromStorage() val baseQuery = timelineEventEntities.where() - val timelineEvents = baseQuery.offsets(direction, count, displayIndex).findAll().orEmpty() - if (timelineEvents.isEmpty()) return 0 - fetchRootThreadEventsIfNeeded(timelineEvents) + + val timelineEvents = baseQuery + .offsets(direction, count, displayIndex) + .findAll() + .orEmpty() + + if (timelineEvents.isEmpty()) return LoadedFromStorage() +// Disabled due to the new fallback +// if(!lightweightSettingsStorage.areThreadMessagesEnabled()) { +// fetchRootThreadEventsIfNeeded(timelineEvents) +// } if (direction == Timeline.Direction.FORWARDS) { builtEventsIndexes.entries.forEach { it.setValue(it.value + timelineEvents.size) } } @@ -291,9 +318,20 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, builtEvents.add(timelineEvent) } } - return timelineEvents.size + return LoadedFromStorage( + threadReachedEnd = threadReachedEnd(timelineEvents), + numberOfEvents = timelineEvents.size) } + /** + * Returns whether or not the the thread has reached end. It returns false if the current timeline + * is not a thread timeline + */ + private fun threadReachedEnd(timelineEvents: List): Boolean = + timelineSettings.rootThreadEventId?.let { rootThreadId -> + timelineEvents.firstOrNull { it.eventId == rootThreadId }?.let { true } + } ?: false + /** * This function is responsible to fetch and store the root event of a thread event * in order to be able to display the event to the user appropriately @@ -316,6 +354,10 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, timelineEvent.root.mxDecryptionResult == null) { timelineEvent.root.eventId?.also { eventDecryptor.requestDecryption(TimelineEventDecryptor.DecryptionRequest(timelineEvent.root, timelineId)) } } + if (!timelineEvent.isEncrypted() && !lightweightSettingsStorage.areThreadMessagesEnabled()) { + // Thread aware for not encrypted events + timelineEvent.root.eventId?.also { eventDecryptor.requestDecryption(TimelineEventDecryptor.DecryptionRequest(timelineEvent.root, timelineId)) } + } return timelineEvent } @@ -343,7 +385,8 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, val loadMoreResult = try { if (token == null) { if (direction == Timeline.Direction.BACKWARDS || !chunkEntity.hasBeenALastForwardChunk()) return LoadMoreResult.REACHED_END - val lastKnownEventId = chunkEntity.sortedTimelineEvents().firstOrNull()?.eventId ?: return LoadMoreResult.FAILURE + val lastKnownEventId = chunkEntity.sortedTimelineEvents(timelineSettings.rootThreadEventId).firstOrNull()?.eventId + ?: return LoadMoreResult.FAILURE val taskParams = FetchTokenAndPaginateTask.Params(roomId, lastKnownEventId, direction.toPaginationDirection(), count) fetchTokenAndPaginateTask.execute(taskParams).toLoadMoreResult() } else { @@ -352,7 +395,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, paginationTask.execute(taskParams).toLoadMoreResult() } } catch (failure: Throwable) { - Timber.e("Failed to fetch from server: $failure", failure) + Timber.e(failure, "Failed to fetch from server") LoadMoreResult.FAILURE } return if (loadMoreResult == LoadMoreResult.SUCCESS) { @@ -450,10 +493,16 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity, timelineEventMapper = timelineEventMapper, uiEchoManager = uiEchoManager, threadsAwarenessHandler = threadsAwarenessHandler, + lightweightSettingsStorage = lightweightSettingsStorage, initialEventId = null, onBuiltEvents = this.onBuiltEvents ) } + + private data class LoadedFromStorage( + val threadReachedEnd: Boolean = false, + val numberOfEvents: Int = 0 + ) } private fun RealmQuery.offsets( @@ -474,6 +523,19 @@ private fun Timeline.Direction.toPaginationDirection(): PaginationDirection { return if (this == Timeline.Direction.BACKWARDS) PaginationDirection.BACKWARDS else PaginationDirection.FORWARDS } -private fun ChunkEntity.sortedTimelineEvents(): RealmResults { - return timelineEvents.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) +private fun ChunkEntity.sortedTimelineEvents(rootThreadEventId: String?): RealmResults { + return if (rootThreadEventId == null) { + timelineEvents + .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) + } else { + timelineEvents + .where() + .beginGroup() + .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .or() + .equalTo(TimelineEventEntityFields.ROOT.EVENT_ID, rootThreadEventId) + .endGroup() + .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING) + .findAll() + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt index 75d02dfd98..49a8a8b55a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineEventDecryptor.kt @@ -23,6 +23,8 @@ import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.internal.crypto.NewSessionListener import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage +import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.SessionDatabase @@ -36,7 +38,8 @@ internal class TimelineEventDecryptor @Inject constructor( @SessionDatabase private val realmConfiguration: RealmConfiguration, private val cryptoService: CryptoService, - private val threadsAwarenessHandler: ThreadsAwarenessHandler + private val threadsAwarenessHandler: ThreadsAwarenessHandler, + private val lightweightSettingsStorage: LightweightSettingsStorage ) { private val newSessionListener = object : NewSessionListener { @@ -101,9 +104,27 @@ internal class TimelineEventDecryptor @Inject constructor( } } + private fun threadAwareNonEncryptedEvents(request: DecryptionRequest, realm: Realm) { + val event = request.event + realm.executeTransaction { + val eventId = event.eventId ?: return@executeTransaction + val eventEntity = EventEntity + .where(it, eventId = eventId) + .findFirst() + val decryptedEvent = eventEntity?.asDomain() + threadsAwarenessHandler.makeEventThreadAware(realm, event.roomId, decryptedEvent, eventEntity) + } + } private fun processDecryptRequest(request: DecryptionRequest, realm: Realm) { val event = request.event val timelineId = request.timelineId + + if (!request.event.isEncrypted()) { + // Here we have requested a decryption to an event that is not encrypted + // We will simply make this event thread aware + threadAwareNonEncryptedEvents(request, realm) + return + } try { val result = cryptoService.decryptEvent(request.event, timelineId) Timber.v("Successfully decrypted event ${event.eventId}") @@ -112,15 +133,9 @@ internal class TimelineEventDecryptor @Inject constructor( val eventEntity = EventEntity .where(it, eventId = eventId) .findFirst() - - eventEntity?.apply { - val decryptedPayload = threadsAwarenessHandler.handleIfNeededDuringDecryption( - it, - roomId = event.roomId, - event, - result) - setDecryptionResult(result, decryptedPayload) - } + eventEntity?.setDecryptionResult(result) + val decryptedEvent = eventEntity?.asDomain() + threadsAwarenessHandler.makeEventThreadAware(realm, event.roomId, decryptedEvent, eventEntity) } } catch (e: MXCryptoError) { Timber.v("Failed to decrypt event ${event.eventId} : ${e.localizedMessage}") diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt index a85f0dbdc9..6607e71bd9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -26,8 +26,11 @@ import org.matrix.android.sdk.api.session.room.send.SendState 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.updateThreadSummaryIfNeeded +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage 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.EventInsertType import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.TimelineEventEntity @@ -36,6 +39,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.where import org.matrix.android.sdk.internal.di.SessionDatabase +import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.session.StreamEventsManager import org.matrix.android.sdk.internal.util.awaitTransaction import timber.log.Timber @@ -45,8 +49,10 @@ import javax.inject.Inject * Insert Chunk in DB, and eventually link next and previous chunk in db. */ internal class TokenChunkEventPersistor @Inject constructor( - @SessionDatabase private val monarchy: Monarchy, - private val liveEventManager: Lazy) { + @SessionDatabase private val monarchy: Monarchy, + @UserId private val userId: String, + private val lightweightSettingsStorage: LightweightSettingsStorage, + private val liveEventManager: Lazy) { enum class Result { SHOULD_FETCH_MORE, @@ -90,6 +96,7 @@ internal class TokenChunkEventPersistor @Inject constructor( handlePagination(realm, roomId, direction, receivedChunk, currentChunk) } } + return if (receivedChunk.events.isEmpty()) { if (receivedChunk.hasMore()) { Result.SHOULD_FETCH_MORE @@ -132,6 +139,7 @@ internal class TokenChunkEventPersistor @Inject constructor( roomMemberContentsByUser[stateEvent.stateKey] = stateEvent.content.toModel() } } + val optimizedThreadSummaryMap = hashMapOf() run processTimelineEvents@{ eventList.forEach { event -> if (event.eventId == null || event.senderId == null) { @@ -176,10 +184,28 @@ internal class TokenChunkEventPersistor @Inject constructor( } liveEventManager.get().dispatchPaginatedEventReceived(event, roomId) currentChunk.addTimelineEvent(roomId, eventEntity, direction, roomMemberContentsByUser) + if (lightweightSettingsStorage.areThreadMessagesEnabled()) { + 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 + } + } } } if (currentChunk.isValid) { RoomEntity.where(realm, roomId).findFirst()?.addIfNecessary(currentChunk) } + + if (lightweightSettingsStorage.areThreadMessagesEnabled()) { + optimizedThreadSummaryMap.updateThreadSummaryIfNeeded( + roomId = roomId, + realm = realm, + currentUserId = userId, + chunkEntity = currentChunk + ) + } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/search/SearchTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/search/SearchTask.kt index 8de762ee1b..3ba7d11c3d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/search/SearchTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/search/SearchTask.kt @@ -19,6 +19,10 @@ package org.matrix.android.sdk.internal.session.search import org.matrix.android.sdk.api.session.search.EventAndSender import org.matrix.android.sdk.api.session.search.SearchResult import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.internal.database.RealmSessionProvider +import org.matrix.android.sdk.internal.database.mapper.asDomain +import org.matrix.android.sdk.internal.database.model.TimelineEventEntity +import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.session.search.request.SearchRequestBody @@ -28,6 +32,7 @@ import org.matrix.android.sdk.internal.session.search.request.SearchRequestFilte import org.matrix.android.sdk.internal.session.search.request.SearchRequestOrder import org.matrix.android.sdk.internal.session.search.request.SearchRequestRoomEvents import org.matrix.android.sdk.internal.session.search.response.SearchResponse +import org.matrix.android.sdk.internal.session.search.response.SearchResponseItem import org.matrix.android.sdk.internal.task.Task import javax.inject.Inject @@ -47,7 +52,8 @@ internal interface SearchTask : Task { internal class DefaultSearchTask @Inject constructor( private val searchAPI: SearchAPI, - private val globalErrorReceiver: GlobalErrorReceiver + private val globalErrorReceiver: GlobalErrorReceiver, + private val realmSessionProvider: RealmSessionProvider ) : SearchTask { override suspend fun execute(params: SearchTask.Params): SearchResult { @@ -74,12 +80,22 @@ internal class DefaultSearchTask @Inject constructor( } private fun SearchResponse.toDomain(): SearchResult { + val localTimelineEvents = findRootThreadEventsFromDB(searchCategories.roomEvents?.results) return SearchResult( nextBatch = searchCategories.roomEvents?.nextBatch, highlights = searchCategories.roomEvents?.highlights, results = searchCategories.roomEvents?.results?.map { searchResponseItem -> + + val localThreadEventDetails = localTimelineEvents + ?.firstOrNull { it.eventId == searchResponseItem.event.eventId } + ?.root + ?.asDomain() + ?.threadDetails + EventAndSender( - searchResponseItem.event, + searchResponseItem.event.apply { + threadDetails = localThreadEventDetails + }, searchResponseItem.event.senderId?.let { senderId -> searchResponseItem.context?.profileInfo?.get(senderId) ?.let { @@ -94,4 +110,19 @@ internal class DefaultSearchTask @Inject constructor( }?.reversed() ) } + + /** + * Find local events if exists in order to enhance the result with thread summary + */ + private fun findRootThreadEventsFromDB(searchResponseItemList: List?): List? { + return realmSessionProvider.withRealm { realm -> + searchResponseItemList?.mapNotNull { + it.event.roomId ?: return@mapNotNull null + it.event.eventId ?: return@mapNotNull null + TimelineEventEntity.where(realm, it.event.roomId, it.event.eventId).findFirst() + }?.filter { + it.root?.isRootThread == true || it.root?.isThread() == true + } + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt index f178074507..f93da9705d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt @@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.sync.model.RoomsSyncResponse import org.matrix.android.sdk.api.session.sync.model.SyncResponse import org.matrix.android.sdk.internal.SessionManager import org.matrix.android.sdk.internal.crypto.DefaultCryptoService +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.SessionId import org.matrix.android.sdk.internal.di.WorkManagerProvider @@ -64,6 +65,7 @@ internal class SyncResponseHandler @Inject constructor( private val aggregatorHandler: SyncResponsePostTreatmentAggregatorHandler, private val cryptoService: DefaultCryptoService, private val tokenStore: SyncTokenStore, + private val lightweightSettingsStorage: LightweightSettingsStorage, private val processEventForPushTask: ProcessEventForPushTask, private val pushRuleService: PushRuleService, private val threadsAwarenessHandler: ThreadsAwarenessHandler, @@ -101,7 +103,10 @@ internal class SyncResponseHandler @Inject constructor( val aggregator = SyncResponsePostTreatmentAggregator() // Prerequisite for thread events handling in RoomSyncHandler - threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse) +// Disabled due to the new fallback +// if (!lightweightSettingsStorage.areThreadMessagesEnabled()) { +// threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse) +// } // Start one big transaction monarchy.awaitTransaction { realm -> diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt index a0d1ebec4d..640fe53727 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt @@ -36,10 +36,13 @@ import org.matrix.android.sdk.internal.crypto.MXCRYPTO_ALGORITHM_MEGOLM import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult import org.matrix.android.sdk.internal.database.helper.addIfNecessary import org.matrix.android.sdk.internal.database.helper.addTimelineEvent +import org.matrix.android.sdk.internal.database.helper.updateThreadSummaryIfNeeded +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.mapper.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.EventInsertType import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.model.RoomMemberSummaryEntity @@ -81,6 +84,7 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle private val threadsAwarenessHandler: ThreadsAwarenessHandler, private val roomChangeMembershipStateDataSource: RoomChangeMembershipStateDataSource, @UserId private val userId: String, + private val lightweightSettingsStorage: LightweightSettingsStorage, private val timelineInput: TimelineInput, private val liveEventService: Lazy) { @@ -363,10 +367,12 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle val eventIds = ArrayList(eventList.size) val roomMemberContentsByUser = HashMap() + val optimizedThreadSummaryMap = hashMapOf() for (event in eventList) { if (event.eventId == null || event.senderId == null || event.type == null) { continue } + eventIds.add(event.eventId) liveEventService.get().dispatchLiveEventReceived(event, roomId, insertType == EventInsertType.INITIAL_SYNC) @@ -375,14 +381,13 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle if (event.isEncrypted() && !isInitialSync) { decryptIfNeeded(event, roomId) } - - threadsAwarenessHandler.handleIfNeeded( - realm = realm, - roomId = roomId, - event = event) + var contentToInject: String? = null + if (!isInitialSync) { + contentToInject = threadsAwarenessHandler.makeEventThreadAware(realm, roomId, event) + } val ageLocalTs = event.unsignedData?.age?.let { syncLocalTimestampMillis - it } - val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, insertType) + val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs, contentToInject).copyToRealmOrIgnore(realm, insertType) if (event.stateKey != null) { CurrentStateEventEntity.getOrCreate(realm, roomId, event.stateKey, event.type).apply { eventId = event.eventId @@ -402,6 +407,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } chunkEntity.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser) + if (lightweightSettingsStorage.areThreadMessagesEnabled()) { + 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) @@ -426,9 +440,15 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle } } } - // Handle deletion of [stuck] local echos if needed - deleteLocalEchosIfNeeded(insertType, roomEntity, eventList) + deleteLocalEchosIfNeeded(insertType, roomEntity, eventList) + if (lightweightSettingsStorage.areThreadMessagesEnabled()) { + optimizedThreadSummaryMap.updateThreadSummaryIfNeeded( + roomId = roomId, + realm = realm, + chunkEntity = chunkEntity, + currentUserId = userId) + } // posting new events to timeline if any is registered timelineInput.onNewTimelineEvents(roomId = roomId, eventIds = eventIds) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt index 767a967522..f3a1523955 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ThreadsAwarenessHandler.kt @@ -18,26 +18,35 @@ package org.matrix.android.sdk.internal.session.sync.handler.room import com.zhuinden.monarchy.Monarchy import io.realm.Realm -import org.matrix.android.sdk.api.session.crypto.CryptoService -import org.matrix.android.sdk.api.session.crypto.MXCryptoError +import io.realm.kotlin.where +import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.getRelationContentForType +import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId +import org.matrix.android.sdk.api.session.events.model.isSticker 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.message.MessageFormat import org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent +import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.sync.model.SyncResponse import org.matrix.android.sdk.api.util.JsonDict -import org.matrix.android.sdk.internal.crypto.MXEventDecryptionResult import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage +import org.matrix.android.sdk.internal.database.mapper.ContentMapper 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.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.query.copyToRealmOrIgnore import org.matrix.android.sdk.internal.database.query.where +import org.matrix.android.sdk.internal.di.MoshiProvider import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory @@ -52,11 +61,16 @@ import javax.inject.Inject */ internal class ThreadsAwarenessHandler @Inject constructor( private val permalinkFactory: PermalinkFactory, - private val cryptoService: CryptoService, @SessionDatabase private val monarchy: Monarchy, + private val lightweightSettingsStorage: LightweightSettingsStorage, private val getEventTask: GetEventTask ) { + // This caching is responsible to improve the performance when we receive a root event + // to be able to know this event is a root one without checking the DB, + // We update the list with all thread root events by checking if there is a m.thread relation on the events + private val cacheEventRootId = hashSetOf() + /** * Fetch root thread events if they are missing from the local storage * @param syncResponse the sync response @@ -84,7 +98,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( if (eventList.isNullOrEmpty()) return val threadsToFetch = emptyMap().toMutableMap() - Realm.getInstance(monarchy.realmConfiguration).use { realm -> + Realm.getInstance(monarchy.realmConfiguration).use { realm -> eventList.asSequence() .filter { isThreadEvent(it) && it.roomId != null @@ -139,96 +153,186 @@ internal class ThreadsAwarenessHandler @Inject constructor( /** * Handle events mainly coming from the RoomSyncHandler + * @return The content to inject in the roomSyncHandler live events */ - fun handleIfNeeded(realm: Realm, - roomId: String, - event: Event) { - val payload = transformThreadToReplyIfNeeded( - realm = realm, - roomId = roomId, - event = event, - decryptedResult = event.mxDecryptionResult?.payload) ?: return - - event.mxDecryptionResult = event.mxDecryptionResult?.copy(payload = payload) - } - - /** - * Handle events while they are being decrypted - */ - fun handleIfNeededDuringDecryption(realm: Realm, - roomId: String?, - event: Event, - result: MXEventDecryptionResult): JsonDict? { - return transformThreadToReplyIfNeeded( - realm = realm, - roomId = roomId, - event = event, - decryptedResult = result.clearEvent) - } - - /** - * If the event is a thread event then transform/enhance it to a visual Reply Event, - * If the event is not a thread event, null value will be returned - * If there is an error (ex. the root/origin thread event is not found), null willl be returend - */ - private fun transformThreadToReplyIfNeeded(realm: Realm, roomId: String?, event: Event, decryptedResult: JsonDict?): JsonDict? { + fun makeEventThreadAware(realm: Realm, + roomId: String?, + event: Event?, + eventEntity: EventEntity? = null): String? { + event ?: return null roomId ?: return null + if (lightweightSettingsStorage.areThreadMessagesEnabled()) return null + handleRootThreadEventsIfNeeded(realm, roomId, eventEntity, event) if (!isThreadEvent(event)) return null - val rootThreadEventId = getRootThreadEventId(event) ?: return null - val payload = decryptedResult?.toMutableMap() ?: return null - val body = getValueFromPayload(payload, "body") ?: return null - val msgType = getValueFromPayload(payload, "msgtype") ?: return null - val rootThreadEvent = getEventFromDB(realm, rootThreadEventId) ?: return null - val rootThreadEventSenderId = rootThreadEvent.senderId ?: return null + val eventPayload = if (!event.isEncrypted()) { + event.content?.toMutableMap() ?: return null + } else { + event.mxDecryptionResult?.payload?.toMutableMap() ?: return null + } + val eventBody = event.getDecryptedTextSummary() ?: return null + val eventIdToInject = getPreviousEventOrRoot(event) ?: run { + return@makeEventThreadAware injectFallbackIndicator(event, eventBody, eventEntity, eventPayload) + } + val eventToInject = getEventFromDB(realm, eventIdToInject) + val eventToInjectBody = eventToInject?.getDecryptedTextSummary() + var contentForNonEncrypted: String? + if (eventToInject != null && eventToInjectBody != null) { + // If the event to inject exists and is decrypted + // Inject it to our event + val messageTextContent = injectEvent( + roomId = roomId, + eventBody = eventBody, + eventToInject = eventToInject, + eventToInjectBody = eventToInjectBody) ?: return null + // update the event + contentForNonEncrypted = updateEventEntity(event, eventEntity, eventPayload, messageTextContent) + } else { + contentForNonEncrypted = injectFallbackIndicator(event, eventBody, eventEntity, eventPayload) + } - decryptIfNeeded(rootThreadEvent, roomId) + // Now lets try to find relations for improved results, while some events may come with reverse order + eventEntity?.let { + // When eventEntity is not null means that we are not from within roomSyncHandler + handleEventsThatRelatesTo(realm, roomId, event, eventBody, false) + } + return contentForNonEncrypted + } - val rootThreadEventBody = getValueFromPayload(rootThreadEvent.mxDecryptionResult?.payload?.toMutableMap(), "body") + /** + * Handle for not thread events that we have marked them as root. + * Find relations and inject them accordingly + * @param eventEntity the current eventEntity received + * @param event the current event received + * @return The content to inject in the roomSyncHandler live events + */ + private fun handleRootThreadEventsIfNeeded(realm: Realm, roomId: String, eventEntity: EventEntity?, event: Event): String? { + if (!isThreadEvent(event) && cacheEventRootId.contains(eventEntity?.eventId)) { + eventEntity?.let { + val eventBody = event.getDecryptedTextSummary() ?: return null + return handleEventsThatRelatesTo(realm, roomId, event, eventBody, true) + } + } + return null + } - val permalink = permalinkFactory.createPermalink(roomId, rootThreadEventId, false) - val userLink = permalinkFactory.createPermalink(rootThreadEventSenderId, false) ?: "" + /** + * This function is responsible to check if there is any event that relates to our current event + * This is useful when we receive an event that relates to a missing parent, so when later we receive the parent + * we can update the child as well + * @param event the current event that we examine + * @param eventBody the current body of the event + * @param isFromCache determines whether or not we already know this is root thread event + * @return The content to inject in the roomSyncHandler live events + */ + private fun handleEventsThatRelatesTo(realm: Realm, roomId: String, event: Event, eventBody: String, isFromCache: Boolean): String? { + event.eventId ?: return null + val rootThreadEventId = if (isFromCache) event.eventId else event.getRootThreadEventId() ?: return null + eventThatRelatesTo(realm, event.eventId, rootThreadEventId)?.forEach { eventEntityFound -> + val newEventFound = eventEntityFound.asDomain() + val newEventBody = newEventFound.getDecryptedTextSummary() ?: return null + val newEventPayload = newEventFound.mxDecryptionResult?.payload?.toMutableMap() ?: return null + val messageTextContent = injectEvent( + roomId = roomId, + eventBody = newEventBody, + eventToInject = event, + eventToInjectBody = eventBody) ?: return null + + return updateEventEntity(newEventFound, eventEntityFound, newEventPayload, messageTextContent) + } + return null + } + + /** + * Actual update the eventEntity with the new payload + * @return the content to inject when this is executed by RoomSyncHandler + */ + private fun updateEventEntity(event: Event, + eventEntity: EventEntity?, + eventPayload: MutableMap, + messageTextContent: Content): String? { + eventPayload["content"] = messageTextContent + + if (event.isEncrypted()) { + if (event.isSticker()) { + eventPayload["type"] = EventType.MESSAGE + } + event.mxDecryptionResult = event.mxDecryptionResult?.copy(payload = eventPayload) + eventEntity?.decryptionResultJson = event.mxDecryptionResult?.let { + MoshiProvider.providesMoshi().adapter(OlmDecryptionResult::class.java).toJson(it) + } + } else { + if (event.type == EventType.STICKER) { + eventEntity?.type = EventType.MESSAGE + } + eventEntity?.content = ContentMapper.map(messageTextContent) + return ContentMapper.map(messageTextContent) + } + return null + } + + /** + * Injecting $eventToInject decrypted content as a reply to $event + * @param eventToInject the event that will inject + * @param eventBody the actual event body + * @return The final content with the injected event + */ + private fun injectEvent(roomId: String, + eventBody: String, + eventToInject: Event, + eventToInjectBody: String): Content? { + val eventToInjectId = eventToInject.eventId ?: return null + val eventIdToInjectSenderId = eventToInject.senderId.orEmpty() + val permalink = permalinkFactory.createPermalink(roomId, eventToInjectId, false) + val userLink = permalinkFactory.createPermalink(eventIdToInjectSenderId, false) ?: "" val replyFormatted = LocalEchoEventFactory.REPLY_PATTERN.format( permalink, userLink, - rootThreadEventSenderId, - // Remove inner mx_reply tags if any - rootThreadEventBody, - body) + eventIdToInjectSenderId, + eventToInjectBody, + eventBody) - val messageTextContent = MessageTextContent( - msgType = msgType, + return MessageTextContent( + msgType = MessageType.MSGTYPE_TEXT, format = MessageFormat.FORMAT_MATRIX_HTML, - body = body, + body = eventBody, formattedBody = replyFormatted ).toContent() - - payload["content"] = messageTextContent - - return payload } /** - * Decrypt the event + * Integrate fallback Quote reply */ + private fun injectFallbackIndicator(event: Event, + eventBody: String, + eventEntity: EventEntity?, + eventPayload: MutableMap): String? { + val replyFormatted = LocalEchoEventFactory.QUOTE_PATTERN.format( + "In reply to a thread", + eventBody) - private fun decryptIfNeeded(event: Event, roomId: String) { - try { - if (!event.isEncrypted() || event.mxDecryptionResult != null) return + val messageTextContent = MessageTextContent( + msgType = MessageType.MSGTYPE_TEXT, + format = MessageFormat.FORMAT_MATRIX_HTML, + body = eventBody, + formattedBody = replyFormatted + ).toContent() - // Event from sync does not have roomId, so add it to the event first - val result = cryptoService.decryptEvent(event.copy(roomId = roomId), "") - event.mxDecryptionResult = OlmDecryptionResult( - payload = result.clearEvent, - senderKey = result.senderCurve25519Key, - keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) }, - forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain - ) - } catch (e: MXCryptoError) { - if (e is MXCryptoError.Base) { - event.mCryptoError = e.errorType - event.mCryptoErrorReason = e.technicalMessage.takeIf { it.isNotEmpty() } ?: e.detailedErrorDescription - } + return updateEventEntity(event, eventEntity, eventPayload, messageTextContent) + } + + private fun eventThatRelatesTo(realm: Realm, currentEventId: String, rootThreadEventId: String): List? { + val threadList = realm.where() + .beginGroup() + .equalTo(EventEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId) + .or() + .equalTo(EventEntityFields.EVENT_ID, rootThreadEventId) + .endGroup() + .and() + .findAll() + cacheEventRootId.add(rootThreadEventId) + return threadList.filter { + it.asDomain().getRelationContentForType(RelationType.IO_THREAD)?.inReplyTo?.eventId == currentEventId } } @@ -246,7 +350,7 @@ internal class ThreadsAwarenessHandler @Inject constructor( * @param event */ private fun isThreadEvent(event: Event): Boolean = - event.content.toModel()?.relatesTo?.type == RelationType.THREAD + event.content.toModel()?.relatesTo?.type == RelationType.IO_THREAD /** * Returns the root thread eventId or null otherwise @@ -255,6 +359,9 @@ internal class ThreadsAwarenessHandler @Inject constructor( private fun getRootThreadEventId(event: Event): String? = event.content.toModel()?.relatesTo?.eventId + private fun getPreviousEventOrRoot(event: Event): String? = + event.content.toModel()?.relatesTo?.inReplyTo?.eventId + @Suppress("UNCHECKED_CAST") private fun getValueFromPayload(payload: JsonDict?, key: String): String? { val content = payload?.get("content") as? JsonDict diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncWorker.kt index 423a4e553f..c67c0e350e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/job/SyncWorker.kt @@ -27,7 +27,6 @@ import org.matrix.android.sdk.internal.di.WorkManagerProvider import org.matrix.android.sdk.internal.session.SessionComponent import org.matrix.android.sdk.internal.session.sync.SyncPresence import org.matrix.android.sdk.internal.session.sync.SyncTask -import org.matrix.android.sdk.internal.task.TaskExecutor import org.matrix.android.sdk.internal.worker.SessionSafeCoroutineWorker import org.matrix.android.sdk.internal.worker.SessionWorkerParams import org.matrix.android.sdk.internal.worker.WorkerParamsFactory @@ -58,7 +57,6 @@ internal class SyncWorker(context: Context, workerParameters: WorkerParameters, ) : SessionWorkerParams @Inject lateinit var syncTask: SyncTask - @Inject lateinit var taskExecutor: TaskExecutor @Inject lateinit var workManagerProvider: WorkManagerProvider override fun injectWith(injector: SessionComponent) { diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt old mode 100644 new mode 100755 diff --git a/tools/check/forbidden_strings_in_layout.txt b/tools/check/forbidden_strings_in_layout.txt old mode 100644 new mode 100755 index 545983f844..e46aa3a0bb --- a/tools/check/forbidden_strings_in_layout.txt +++ b/tools/check/forbidden_strings_in_layout.txt @@ -24,7 +24,7 @@ # Extension:xml ### Use style="@style/Widget.Vector.TextView.*" instead of textSize attribute -android:textSize===9 +android:textSize===11 ### Use `@id` and not `@+id` when referencing ids in layouts layout_(.*)="@\+id diff --git a/vector/build.gradle b/vector/build.gradle index 54d23863ab..40c1da0cdc 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -153,6 +153,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}" + buildConfigField "Boolean", "enableLocationSharing", "true" buildConfigField "String", "mapTilerKey", "\"fU3vlMsMn4Jb6dnEIFsx\"" @@ -288,9 +291,8 @@ android { } } - lintOptions { - lintConfig file("lint.xml") - + lint { + lintConfig file('lint.xml') checkDependencies true abortOnError true } diff --git a/vector/lint.xml b/vector/lint.xml index f02090489c..f2387c58cb 100644 --- a/vector/lint.xml +++ b/vector/lint.xml @@ -6,6 +6,7 @@ + diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt index d7e99c63dd..a5962d16fe 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt @@ -145,7 +145,7 @@ class ElementRobot { assertDisplayed(R.string.are_you_sure) clickOn(R.string.action_skip) waitUntilViewVisible(withId(R.id.bottomSheetFragmentContainer)) - }.onFailure { Timber.w("Verification popup missing", it) } + }.onFailure { Timber.w(it, "Verification popup missing") } } } diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index fdec5337ba..2e5412870f 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -175,6 +175,8 @@ + + diff --git a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt index 5d27909b25..e7aa83ae75 100644 --- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt @@ -58,9 +58,10 @@ import im.vector.app.features.home.HomeDetailFragment import im.vector.app.features.home.HomeDrawerFragment import im.vector.app.features.home.LoadingFragment import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment -import im.vector.app.features.home.room.detail.RoomDetailFragment +import im.vector.app.features.home.room.detail.TimelineFragment import im.vector.app.features.home.room.detail.search.SearchFragment import im.vector.app.features.home.room.list.RoomListFragment +import im.vector.app.features.home.room.threads.list.views.ThreadListFragment import im.vector.app.features.location.LocationPreviewFragment import im.vector.app.features.location.LocationSharingFragment import im.vector.app.features.login.LoginCaptchaFragment @@ -204,8 +205,8 @@ interface FragmentModule { @Binds @IntoMap - @FragmentKey(RoomDetailFragment::class) - fun bindRoomDetailFragment(fragment: RoomDetailFragment): Fragment + @FragmentKey(TimelineFragment::class) + fun bindTimelineFragment(fragment: TimelineFragment): Fragment @Binds @IntoMap @@ -937,6 +938,11 @@ interface FragmentModule { @FragmentKey(SpaceLeaveAdvancedFragment::class) fun bindSpaceLeaveAdvancedFragment(fragment: SpaceLeaveAdvancedFragment): Fragment + @Binds + @IntoMap + @FragmentKey(ThreadListFragment::class) + fun bindThreadListFragment(fragment: ThreadListFragment): Fragment + @Binds @IntoMap @FragmentKey(CreatePollFragment::class) diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 9ad01cd3e4..7bc703cb97 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -44,7 +44,7 @@ import im.vector.app.features.home.UnknownDeviceDetectorSharedViewModel import im.vector.app.features.home.UnreadMessagesSharedViewModel import im.vector.app.features.home.UserColorAccountDataViewModel import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsViewModel -import im.vector.app.features.home.room.detail.RoomDetailViewModel +import im.vector.app.features.home.room.detail.TimelineViewModel import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel import im.vector.app.features.home.room.detail.search.SearchViewModel import im.vector.app.features.home.room.detail.timeline.action.MessageActionsViewModel @@ -61,6 +61,7 @@ import im.vector.app.features.login2.created.AccountCreatedViewModel import im.vector.app.features.matrixto.MatrixToBottomSheetViewModel import im.vector.app.features.onboarding.OnboardingViewModel import im.vector.app.features.poll.create.CreatePollViewModel +import im.vector.app.features.qrcode.QrCodeScannerViewModel import im.vector.app.features.rageshake.BugReportViewModel import im.vector.app.features.reactions.EmojiSearchResultViewModel import im.vector.app.features.room.RequireActiveMembershipViewModel @@ -220,6 +221,11 @@ interface MavericksViewModelModule { @MavericksViewModelKey(CreateDirectRoomViewModel::class) fun createDirectRoomViewModelFactory(factory: CreateDirectRoomViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + @Binds + @IntoMap + @MavericksViewModelKey(QrCodeScannerViewModel::class) + fun qrCodeViewModelFactory(factory: QrCodeScannerViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + @Binds @IntoMap @MavericksViewModelKey(RoomNotificationSettingsViewModel::class) @@ -537,8 +543,8 @@ interface MavericksViewModelModule { @Binds @IntoMap - @MavericksViewModelKey(RoomDetailViewModel::class) - fun roomDetailViewModelFactory(factory: RoomDetailViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + @MavericksViewModelKey(TimelineViewModel::class) + fun roomDetailViewModelFactory(factory: TimelineViewModel.Factory): MavericksAssistedViewModelFactory<*, *> @Binds @IntoMap diff --git a/vector/src/main/java/im/vector/app/core/extensions/Activity.kt b/vector/src/main/java/im/vector/app/core/extensions/Activity.kt index aa96a4a30c..829790f857 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/Activity.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/Activity.kt @@ -28,6 +28,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentTransaction +import im.vector.app.R fun ComponentActivity.registerStartForActivityResult(onResult: (ActivityResult) -> Unit): ActivityResultLauncher { return registerForActivityResult(ActivityResultContracts.StartActivityForResult(), onResult) @@ -66,8 +67,12 @@ fun AppCompatActivity.replaceFragment( fragmentClass: Class, params: Parcelable? = null, tag: String? = null, - allowStateLoss: Boolean = false) { + allowStateLoss: Boolean = false, + useCustomAnimation: Boolean = false) { supportFragmentManager.commitTransaction(allowStateLoss) { + if (useCustomAnimation) { + setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) + } replace(container.id, fragmentClass, params.toMvRxBundle(), tag) } } diff --git a/vector/src/main/java/im/vector/app/core/extensions/TextView.kt b/vector/src/main/java/im/vector/app/core/extensions/TextView.kt index cb34b95fa1..0564f2055b 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/TextView.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/TextView.kt @@ -129,6 +129,10 @@ fun TextView.setLeftDrawable(drawable: Drawable?) { setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null) } +fun TextView.clearDrawables() { + setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0) +} + /** * Set long click listener to copy the current text of the TextView to the clipboard and show a Snackbar */ diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorDummyViewState.kt b/vector/src/main/java/im/vector/app/core/platform/VectorDummyViewState.kt new file mode 100644 index 0000000000..3c293b1072 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/platform/VectorDummyViewState.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.platform + +import com.airbnb.mvrx.MavericksState + +data class VectorDummyViewState( + val isDummy: Unit = Unit +) : MavericksState diff --git a/vector/src/main/java/im/vector/app/core/resources/UserPreferencesProvider.kt b/vector/src/main/java/im/vector/app/core/resources/UserPreferencesProvider.kt index 9ab3b9bf45..3aa1964d8d 100644 --- a/vector/src/main/java/im/vector/app/core/resources/UserPreferencesProvider.kt +++ b/vector/src/main/java/im/vector/app/core/resources/UserPreferencesProvider.kt @@ -48,4 +48,8 @@ class UserPreferencesProvider @Inject constructor(private val vectorPreferences: fun shouldShowAvatarDisplayNameChanges(): Boolean { return vectorPreferences.showAvatarDisplayNameChangeMessages() } + + fun areThreadMessagesEnabled(): Boolean { + return vectorPreferences.areThreadMessagesEnabled() + } } diff --git a/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt b/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt index 5d65d7ea42..3b92e7c4de 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/accountdata/AnalyticsAccountDataViewModel.kt @@ -17,7 +17,6 @@ package im.vector.app.features.analytics.accountdata import androidx.lifecycle.asFlow -import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksViewModelFactory import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -26,6 +25,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.EmptyAction import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorDummyViewState import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.analytics.VectorAnalytics import im.vector.app.features.analytics.log.analyticsTag @@ -42,24 +42,20 @@ import org.matrix.android.sdk.flow.flow import timber.log.Timber import java.util.UUID -data class DummyState( - val dummy: Boolean = false -) : MavericksState - class AnalyticsAccountDataViewModel @AssistedInject constructor( - @Assisted initialState: DummyState, + @Assisted initialState: VectorDummyViewState, private val session: Session, private val analytics: VectorAnalytics -) : VectorViewModel(initialState) { +) : VectorViewModel(initialState) { private var checkDone: Boolean = false @AssistedFactory - interface Factory : MavericksAssistedViewModelFactory { - override fun create(initialState: DummyState): AnalyticsAccountDataViewModel + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: VectorDummyViewState): AnalyticsAccountDataViewModel } - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { private const val ANALYTICS_EVENT_TYPE = "im.vector.analytics" } diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt index 9888f1e35e..5e4528d381 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/command/AutocompleteCommandPresenter.kt @@ -18,17 +18,26 @@ package im.vector.app.features.autocomplete.command import android.content.Context import androidx.recyclerview.widget.RecyclerView +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import im.vector.app.features.autocomplete.AutocompleteClickListener import im.vector.app.features.autocomplete.RecyclerViewPresenter import im.vector.app.features.command.Command import im.vector.app.features.settings.VectorPreferences -import javax.inject.Inject -class AutocompleteCommandPresenter @Inject constructor(context: Context, - private val controller: AutocompleteCommandController, - private val vectorPreferences: VectorPreferences) : +class AutocompleteCommandPresenter @AssistedInject constructor( + @Assisted val isInThreadTimeline: Boolean, + context: Context, + private val controller: AutocompleteCommandController, + private val vectorPreferences: VectorPreferences) : RecyclerViewPresenter(context), AutocompleteClickListener { + @AssistedFactory + interface Factory { + fun create(isFromThreadTimeline: Boolean): AutocompleteCommandPresenter + } + init { controller.listener = this } @@ -46,6 +55,13 @@ class AutocompleteCommandPresenter @Inject constructor(context: Context, .filter { !it.isDevCommand || vectorPreferences.developerMode() } + .filter { + if (vectorPreferences.areThreadMessagesEnabled() && isInThreadTimeline) { + it.isThreadCommand + } else { + true + } + } .filter { if (query.isNullOrEmpty()) { true diff --git a/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt b/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt index 642d259723..caab4c85e1 100644 --- a/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt +++ b/vector/src/main/java/im/vector/app/features/call/VectorCallActivity.kt @@ -60,7 +60,7 @@ import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.RoomDetailActivity -import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import io.github.hyuwah.draggableviewlib.DraggableView import io.github.hyuwah.draggableviewlib.setupDraggable import kotlinx.parcelize.Parcelize @@ -571,7 +571,7 @@ class VectorCallActivity : VectorBaseActivity(), CallContro private fun returnToChat() { val roomId = withState(callViewModel) { it.roomId } - val args = RoomDetailArgs(roomId) + val args = TimelineArgs(roomId) val intent = RoomDetailActivity.newIntent(this, args).apply { flags = FLAG_ACTIVITY_CLEAR_TOP } diff --git a/vector/src/main/java/im/vector/app/features/command/Command.kt b/vector/src/main/java/im/vector/app/features/command/Command.kt index 01f8cf234b..421c83c9fe 100644 --- a/vector/src/main/java/im/vector/app/features/command/Command.kt +++ b/vector/src/main/java/im/vector/app/features/command/Command.kt @@ -28,41 +28,42 @@ enum class Command(val command: String, val aliases: Array?, val parameters: String, @StringRes val description: Int, - val isDevCommand: Boolean) { - EMOTE("/me", null, "", R.string.command_description_emote, false), - BAN_USER("/ban", null, " [reason]", R.string.command_description_ban_user, false), - UNBAN_USER("/unban", null, " [reason]", R.string.command_description_unban_user, false), - IGNORE_USER("/ignore", null, " [reason]", R.string.command_description_ignore_user, false), - UNIGNORE_USER("/unignore", null, "", R.string.command_description_unignore_user, false), - SET_USER_POWER_LEVEL("/op", null, " []", R.string.command_description_op_user, false), - RESET_USER_POWER_LEVEL("/deop", null, "", R.string.command_description_deop_user, false), - ROOM_NAME("/roomname", null, "", R.string.command_description_room_name, false), - INVITE("/invite", null, " [reason]", R.string.command_description_invite_user, false), - JOIN_ROOM("/join", arrayOf("/j", "/goto"), " [reason]", R.string.command_description_join_room, false), - PART("/part", null, "[]", R.string.command_description_part_room, false), - TOPIC("/topic", null, "", R.string.command_description_topic, false), - REMOVE_USER("/remove", arrayOf("/kick"), " [reason]", R.string.command_description_remove_user, false), - CHANGE_DISPLAY_NAME("/nick", null, "", R.string.command_description_nick, false), - CHANGE_DISPLAY_NAME_FOR_ROOM("/myroomnick", arrayOf("/roomnick"), "", R.string.command_description_nick_for_room, false), - ROOM_AVATAR("/roomavatar", null, "", R.string.command_description_room_avatar, true /* Since user has to know the mxc url */), - CHANGE_AVATAR_FOR_ROOM("/myroomavatar", null, "", R.string.command_description_avatar_for_room, true /* Since user has to know the mxc url */), - MARKDOWN("/markdown", null, "", R.string.command_description_markdown, false), - RAINBOW("/rainbow", null, "", R.string.command_description_rainbow, false), - RAINBOW_EMOTE("/rainbowme", null, "", R.string.command_description_rainbow_emote, false), - CLEAR_SCALAR_TOKEN("/clear_scalar_token", null, "", R.string.command_description_clear_scalar_token, false), - SPOILER("/spoiler", null, "", R.string.command_description_spoiler, false), - SHRUG("/shrug", null, "", R.string.command_description_shrug, false), - LENNY("/lenny", null, "", R.string.command_description_lenny, false), - PLAIN("/plain", null, "", R.string.command_description_plain, false), - WHOIS("/whois", null, "", R.string.command_description_whois, false), - DISCARD_SESSION("/discardsession", null, "", R.string.command_description_discard_session, false), - CONFETTI("/confetti", null, "", R.string.command_confetti, false), - SNOWFALL("/snowfall", null, "", R.string.command_snow, false), - CREATE_SPACE("/createspace", null, " *", R.string.command_description_create_space, true), - ADD_TO_SPACE("/addToSpace", null, "spaceId", R.string.command_description_add_to_space, true), - JOIN_SPACE("/joinSpace", null, "spaceId", R.string.command_description_join_space, true), - LEAVE_ROOM("/leave", null, "", R.string.command_description_leave_room, true), - UPGRADE_ROOM("/upgraderoom", null, "newVersion", R.string.command_description_upgrade_room, true); + val isDevCommand: Boolean, + val isThreadCommand: Boolean) { + EMOTE("/me", null, "", R.string.command_description_emote, false, true), + BAN_USER("/ban", null, " [reason]", R.string.command_description_ban_user, false, false), + UNBAN_USER("/unban", null, " [reason]", R.string.command_description_unban_user, false, false), + IGNORE_USER("/ignore", null, " [reason]", R.string.command_description_ignore_user, false, true), + UNIGNORE_USER("/unignore", null, "", R.string.command_description_unignore_user, false, true), + SET_USER_POWER_LEVEL("/op", null, " []", R.string.command_description_op_user, false, false), + RESET_USER_POWER_LEVEL("/deop", null, "", R.string.command_description_deop_user, false, false), + ROOM_NAME("/roomname", null, "", R.string.command_description_room_name, false, false), + INVITE("/invite", null, " [reason]", R.string.command_description_invite_user, false, false), + JOIN_ROOM("/join", arrayOf("/j", "/goto"), " [reason]", R.string.command_description_join_room, false, false), + PART("/part", null, "[]", R.string.command_description_part_room, false, false), + TOPIC("/topic", null, "", R.string.command_description_topic, false, false), + REMOVE_USER("/remove", arrayOf("/kick"), " [reason]", R.string.command_description_remove_user, false, false), + CHANGE_DISPLAY_NAME("/nick", null, "", R.string.command_description_nick, false, false), + CHANGE_DISPLAY_NAME_FOR_ROOM("/myroomnick", arrayOf("/roomnick"), "", R.string.command_description_nick_for_room, false, false), + ROOM_AVATAR("/roomavatar", null, "", R.string.command_description_room_avatar, true /* User has to know the mxc url */, false), + CHANGE_AVATAR_FOR_ROOM("/myroomavatar", null, "", R.string.command_description_avatar_for_room, true /* User has to know the mxc url */, false), + MARKDOWN("/markdown", null, "", R.string.command_description_markdown, false, false), + RAINBOW("/rainbow", null, "", R.string.command_description_rainbow, false, true), + RAINBOW_EMOTE("/rainbowme", null, "", R.string.command_description_rainbow_emote, false, true), + CLEAR_SCALAR_TOKEN("/clear_scalar_token", null, "", R.string.command_description_clear_scalar_token, false, false), + SPOILER("/spoiler", null, "", R.string.command_description_spoiler, false, true), + SHRUG("/shrug", null, "", R.string.command_description_shrug, false, true), + LENNY("/lenny", null, "", R.string.command_description_lenny, false, true), + PLAIN("/plain", null, "", R.string.command_description_plain, false, true), + WHOIS("/whois", null, "", R.string.command_description_whois, false, true), + DISCARD_SESSION("/discardsession", null, "", R.string.command_description_discard_session, false, false), + CONFETTI("/confetti", null, "", R.string.command_confetti, false, false), + SNOWFALL("/snowfall", null, "", R.string.command_snow, false, false), + CREATE_SPACE("/createspace", null, " *", R.string.command_description_create_space, true, false), + ADD_TO_SPACE("/addToSpace", null, "spaceId", R.string.command_description_add_to_space, true, false), + JOIN_SPACE("/joinSpace", null, "spaceId", R.string.command_description_join_space, true, false), + LEAVE_ROOM("/leave", null, "", R.string.command_description_leave_room, true, false), + UPGRADE_ROOM("/upgraderoom", null, "newVersion", R.string.command_description_upgrade_room, true, false); val allAliases = arrayOf(command, *aliases.orEmpty()) diff --git a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt index 9d854fdbee..b8bef506b1 100644 --- a/vector/src/main/java/im/vector/app/features/command/CommandParser.kt +++ b/vector/src/main/java/im/vector/app/features/command/CommandParser.kt @@ -33,7 +33,7 @@ class CommandParser @Inject constructor() { * @param textMessage the text message * @return a parsed slash command (ok or error) */ - fun parseSlashCommand(textMessage: CharSequence): ParsedCommand { + fun parseSlashCommand(textMessage: CharSequence, isInThreadTimeline: Boolean): ParsedCommand { // check if it has the Slash marker return if (!textMessage.startsWith("/")) { ParsedCommand.ErrorNotACommand @@ -63,6 +63,10 @@ class CommandParser @Inject constructor() { val slashCommand = messageParts.first() val message = textMessage.substring(slashCommand.length).trim() + getNotSupportedByThreads(isInThreadTimeline, slashCommand)?.let { + return ParsedCommand.ErrorCommandNotSupportedInThreads(it) + } + when { Command.PLAIN.matches(slashCommand) -> { if (message.isNotEmpty()) { @@ -400,6 +404,28 @@ class CommandParser @Inject constructor() { } } + private val notSupportedThreadsCommands: List by lazy { + Command.values().filter { + !it.isThreadCommand + } + } + + /** + * Checks whether or not the current command is not supported by threads + * @param slashCommand the slash command that will be checked + * @param isInThreadTimeline if its true we are in a thread timeline + * @return The command that is not supported + */ + private fun getNotSupportedByThreads(isInThreadTimeline: Boolean, slashCommand: String): Command? { + return if (isInThreadTimeline) { + notSupportedThreadsCommands.firstOrNull { + it.command == slashCommand + } + } else { + null + } + } + private fun trimParts(message: CharSequence, messageParts: List): String? { val partsSize = messageParts.sumOf { it.length } val gapsNumber = messageParts.size - 1 diff --git a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt index 5f2e7f56a5..771f721d3c 100644 --- a/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt +++ b/vector/src/main/java/im/vector/app/features/command/ParsedCommand.kt @@ -28,6 +28,8 @@ sealed interface ParsedCommand { object ErrorEmptySlashCommand : ParsedCommand + class ErrorCommandNotSupportedInThreads(val command: Command) : ParsedCommand + // Unknown/Unsupported slash command data class ErrorUnknownSlashCommand(val slashCommand: String) : ParsedCommand diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt index da3425d326..83c7f0a13b 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomAction.kt @@ -23,4 +23,8 @@ sealed class CreateDirectRoomAction : VectorViewModelAction { data class CreateRoomAndInviteSelectedUsers( val selections: Set ) : CreateDirectRoomAction() + + data class QrScannedAction( + val result: String + ) : CreateDirectRoomAction() } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt index 0df9426852..2d93bab6a3 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomActivity.kt @@ -22,6 +22,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.View +import android.widget.Toast import androidx.lifecycle.lifecycleScope import com.airbnb.mvrx.Async import com.airbnb.mvrx.Fail @@ -44,6 +45,10 @@ import im.vector.app.core.utils.onPermissionDeniedSnackbar import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.features.analytics.plan.Screen import im.vector.app.features.contactsbook.ContactsBookFragment +import im.vector.app.features.qrcode.QrCodeScannerEvents +import im.vector.app.features.qrcode.QrCodeScannerFragment +import im.vector.app.features.qrcode.QrCodeScannerViewModel +import im.vector.app.features.qrcode.QrScannerArgs import im.vector.app.features.userdirectory.UserListFragment import im.vector.app.features.userdirectory.UserListFragmentArgs import im.vector.app.features.userdirectory.UserListSharedAction @@ -59,6 +64,8 @@ import javax.inject.Inject class CreateDirectRoomActivity : SimpleFragmentActivity() { private val viewModel: CreateDirectRoomViewModel by viewModel() + private val qrViewModel: QrCodeScannerViewModel by viewModel() + private lateinit var sharedActionViewModel: UserListSharedActionViewModel @Inject lateinit var errorFormatter: ErrorFormatter @@ -93,11 +100,38 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { viewModel.onEach(CreateDirectRoomViewState::createAndInviteState) { renderCreateAndInviteState(it) } + + viewModel.observeViewEvents { + when (it) { + CreateDirectRoomViewEvents.InvalidCode -> { + Toast.makeText(this, R.string.invalid_qr_code_uri, Toast.LENGTH_SHORT).show() + finish() + } + CreateDirectRoomViewEvents.DmSelf -> { + Toast.makeText(this, R.string.cannot_dm_self, Toast.LENGTH_SHORT).show() + finish() + } + }.exhaustive + } + + qrViewModel.observeViewEvents { + when (it) { + is QrCodeScannerEvents.CodeParsed -> { + viewModel.handle(CreateDirectRoomAction.QrScannedAction(it.result)) + } + is QrCodeScannerEvents.ParseFailed -> { + Toast.makeText(this, R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show() + finish() + } + else -> Unit + }.exhaustive + } } private fun openAddByQrCode() { if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, this, permissionCameraLauncher)) { - addFragment(views.container, CreateDirectRoomByQrCodeFragment::class.java) + val args = QrScannerArgs(showExtraButtons = false, R.string.add_by_qr_code) + addFragment(views.container, QrCodeScannerFragment::class.java, args) } } @@ -118,7 +152,7 @@ class CreateDirectRoomActivity : SimpleFragmentActivity() { private val permissionCameraLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> if (allGranted) { - addFragment(views.container, CreateDirectRoomByQrCodeFragment::class.java) + addFragment(views.container, QrCodeScannerFragment::class.java) } else if (deniedPermanently) { onPermissionDeniedSnackbar(R.string.permissions_denied_qr_code) } diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt deleted file mode 100644 index 766a6f5156..0000000000 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomByQrCodeFragment.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2020 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.createdirect - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import com.airbnb.mvrx.activityViewModel -import com.google.zxing.Result -import com.google.zxing.ResultMetadataType -import im.vector.app.R -import im.vector.app.core.extensions.hideKeyboard -import im.vector.app.core.platform.VectorBaseFragment -import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO -import im.vector.app.core.utils.checkPermissions -import im.vector.app.core.utils.onPermissionDeniedDialog -import im.vector.app.core.utils.registerForPermissionsResult -import im.vector.app.databinding.FragmentQrCodeScannerBinding -import im.vector.app.features.userdirectory.PendingSelection -import me.dm7.barcodescanner.zxing.ZXingScannerView -import org.matrix.android.sdk.api.session.permalinks.PermalinkData -import org.matrix.android.sdk.api.session.permalinks.PermalinkParser -import org.matrix.android.sdk.api.session.user.model.User -import javax.inject.Inject - -class CreateDirectRoomByQrCodeFragment @Inject constructor() : VectorBaseFragment(), ZXingScannerView.ResultHandler { - - private val viewModel: CreateDirectRoomViewModel by activityViewModel() - - override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentQrCodeScannerBinding { - return FragmentQrCodeScannerBinding.inflate(inflater, container, false) - } - - private val openCameraActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> - if (allGranted) { - startCamera() - } else if (deniedPermanently) { - activity?.onPermissionDeniedDialog(R.string.denied_permission_camera) - } - } - - private fun startCamera() { - // Start camera on resume - views.scannerView.startCamera() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupToolbar(views.qrScannerToolbar) - .setTitle(R.string.add_by_qr_code) - .allowBack(useCross = true) - } - - override fun onResume() { - super.onResume() - view?.hideKeyboard() - // Register ourselves as a handler for scan results. - views.scannerView.setResultHandler(this) - // Start camera on resume - if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), openCameraActivityResultLauncher)) { - startCamera() - } - } - - override fun onPause() { - super.onPause() - // Unregister ourselves as a handler for scan results. - views.scannerView.setResultHandler(null) - // Stop camera on pause - views.scannerView.stopCamera() - } - - // Copied from https://github.com/markusfisch/BinaryEye/blob/ - // 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434 - private fun getRawBytes(result: Result): ByteArray? { - val metadata = result.resultMetadata ?: return null - val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null - var bytes = ByteArray(0) - @Suppress("UNCHECKED_CAST") - for (seg in segments as Iterable) { - bytes += seg - } - // byte segments can never be shorter than the text. - // Zxing cuts off content prefixes like "WIFI:" - return if (bytes.size >= result.text.length) bytes else null - } - - private fun addByQrCode(value: String) { - val mxid = (PermalinkParser.parse(value) as? PermalinkData.UserLink)?.userId - - if (mxid === null) { - Toast.makeText(requireContext(), R.string.invalid_qr_code_uri, Toast.LENGTH_SHORT).show() - requireActivity().finish() - } else { - // The following assumes MXIDs are case insensitive - if (mxid.equals(other = viewModel.session.myUserId, ignoreCase = true)) { - Toast.makeText(requireContext(), R.string.cannot_dm_self, Toast.LENGTH_SHORT).show() - requireActivity().finish() - } else { - // Try to get user from known users and fall back to creating a User object from MXID - val qrInvitee = if (viewModel.session.getUser(mxid) != null) viewModel.session.getUser(mxid)!! else User(mxid, null, null) - - viewModel.handle( - CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers(setOf(PendingSelection.UserPendingSelection(qrInvitee))) - ) - } - } - } - - override fun handleResult(result: Result?) { - if (result === null) { - Toast.makeText(requireContext(), R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show() - requireActivity().finish() - } else { - val rawBytes = getRawBytes(result) - val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1) - val value = rawBytesStr ?: result.text - addByQrCode(value) - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewEvents.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewEvents.kt index 0c9804e9a4..060cb0c327 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewEvents.kt @@ -18,4 +18,7 @@ package im.vector.app.features.createdirect import im.vector.app.core.platform.VectorViewEvents -sealed class CreateDirectRoomViewEvents : VectorViewEvents +sealed class CreateDirectRoomViewEvents : VectorViewEvents { + object InvalidCode : CreateDirectRoomViewEvents() + object DmSelf : CreateDirectRoomViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt index 41360eab93..9dd3ef6a9b 100644 --- a/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/createdirect/CreateDirectRoomViewModel.kt @@ -34,13 +34,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.permalinks.PermalinkData +import org.matrix.android.sdk.api.session.permalinks.PermalinkParser import org.matrix.android.sdk.api.session.room.model.create.CreateRoomParams +import org.matrix.android.sdk.api.session.user.model.User class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted initialState: CreateDirectRoomViewState, private val rawService: RawService, val session: Session) : - VectorViewModel(initialState) { + VectorViewModel(initialState) { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -51,15 +54,33 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted override fun handle(action: CreateDirectRoomAction) { when (action) { - is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action) + is CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers -> onSubmitInvitees(action.selections) + is CreateDirectRoomAction.QrScannedAction -> onCodeParsed(action) }.exhaustive } + private fun onCodeParsed(action: CreateDirectRoomAction.QrScannedAction) { + val mxid = (PermalinkParser.parse(action.result) as? PermalinkData.UserLink)?.userId + + if (mxid === null) { + _viewEvents.post(CreateDirectRoomViewEvents.InvalidCode) + } else { + // The following assumes MXIDs are case insensitive + if (mxid.equals(other = session.myUserId, ignoreCase = true)) { + _viewEvents.post(CreateDirectRoomViewEvents.DmSelf) + } else { + // Try to get user from known users and fall back to creating a User object from MXID + val qrInvitee = if (session.getUser(mxid) != null) session.getUser(mxid)!! else User(mxid, null, null) + onSubmitInvitees(setOf(PendingSelection.UserPendingSelection(qrInvitee))) + } + } + } + /** * If users already have a DM room then navigate to it instead of creating a new room. */ - private fun onSubmitInvitees(action: CreateDirectRoomAction.CreateRoomAndInviteSelectedUsers) { - val existingRoomId = action.selections.singleOrNull()?.getMxId()?.let { userId -> + private fun onSubmitInvitees(selections: Set) { + val existingRoomId = selections.singleOrNull()?.getMxId()?.let { userId -> session.getExistingDirectRoomWithUser(userId) } if (existingRoomId != null) { @@ -69,7 +90,7 @@ class CreateDirectRoomViewModel @AssistedInject constructor(@Assisted } } else { // Create the DM - createRoomAndInviteSelectedUsers(action.selections) + createRoomAndInviteSelectedUsers(selections) } } diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt index 14ffda58a9..3a3f1054f1 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt @@ -21,7 +21,7 @@ import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.RoomDetailActivity -import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.popup.VerificationVectorAlert import org.matrix.android.sdk.api.session.Session @@ -142,7 +142,7 @@ class IncomingVerificationRequestHandler @Inject constructor( R.drawable.ic_shield_black, shouldBeDisplayedIn = { activity -> if (activity is RoomDetailActivity) { - activity.intent?.extras?.getParcelable(RoomDetailActivity.EXTRA_ROOM_DETAIL_ARGS)?.let { + activity.intent?.extras?.getParcelable(RoomDetailActivity.EXTRA_ROOM_DETAIL_ARGS)?.let { it.roomId != pr.roomId } ?: true } else true diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt index d9719cb28f..b083b74c53 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt @@ -550,7 +550,7 @@ class HomeActivity : return true } - override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?, rootThreadEventId: String?): Boolean { if (roomId == null) return false MatrixToBottomSheet.withLink(deepLink.toString()) .show(supportFragmentManager, "HA#MatrixToBottomSheet") diff --git a/vector/src/main/java/im/vector/app/features/home/UserColorAccountDataViewModel.kt b/vector/src/main/java/im/vector/app/features/home/UserColorAccountDataViewModel.kt index 3d4f219a7c..37e15af8b3 100644 --- a/vector/src/main/java/im/vector/app/features/home/UserColorAccountDataViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/UserColorAccountDataViewModel.kt @@ -16,7 +16,6 @@ package im.vector.app.features.home -import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksViewModelFactory import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -25,6 +24,7 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.EmptyAction import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorDummyViewState import im.vector.app.core.platform.VectorViewModel import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider import kotlinx.coroutines.flow.launchIn @@ -37,22 +37,18 @@ import org.matrix.android.sdk.flow.flow import org.matrix.android.sdk.flow.unwrap import timber.log.Timber -data class DummyState( - val dummy: Boolean = false -) : MavericksState - class UserColorAccountDataViewModel @AssistedInject constructor( - @Assisted initialState: DummyState, + @Assisted initialState: VectorDummyViewState, private val session: Session, private val matrixItemColorProvider: MatrixItemColorProvider -) : VectorViewModel(initialState) { +) : VectorViewModel(initialState) { @AssistedFactory - interface Factory : MavericksAssistedViewModelFactory { - override fun create(initialState: DummyState): UserColorAccountDataViewModel + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: VectorDummyViewState): UserColorAccountDataViewModel } - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() init { observeAccountData() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt index 7dbd8cc3b5..9f85d4015b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/AutoCompleter.kt @@ -49,9 +49,10 @@ import org.matrix.android.sdk.api.util.toRoomAliasMatrixItem class AutoCompleter @AssistedInject constructor( @Assisted val roomId: String, + @Assisted val isInThreadTimeline: Boolean, private val avatarRenderer: AvatarRenderer, private val commandAutocompletePolicy: CommandAutocompletePolicy, - private val autocompleteCommandPresenter: AutocompleteCommandPresenter, + AutocompleteCommandPresenterFactory: AutocompleteCommandPresenter.Factory, private val autocompleteMemberPresenterFactory: AutocompleteMemberPresenter.Factory, private val autocompleteRoomPresenter: AutocompleteRoomPresenter, private val autocompleteGroupPresenter: AutocompleteGroupPresenter, @@ -62,7 +63,11 @@ class AutoCompleter @AssistedInject constructor( @AssistedFactory interface Factory { - fun create(roomId: String): AutoCompleter + fun create(roomId: String, isInThreadTimeline: Boolean): AutoCompleter + } + + private val autocompleteCommandPresenter: AutocompleteCommandPresenter by lazy { + AutocompleteCommandPresenterFactory.create(isInThreadTimeline) } private var editText: EditText? = null diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/JoinReplacementRoomBottomSheet.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/JoinReplacementRoomBottomSheet.kt index ba559677c9..99843084ec 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/JoinReplacementRoomBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/JoinReplacementRoomBottomSheet.kt @@ -44,7 +44,7 @@ class JoinReplacementRoomBottomSheet : @Inject lateinit var errorFormatter: ErrorFormatter - private val viewModel: RoomDetailViewModel by parentFragmentViewModel() + private val viewModel: TimelineViewModel by parentFragmentViewModel() override val showExpanded: Boolean get() = true diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt index 9a7b8e64f7..ae24052aa2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt @@ -38,6 +38,7 @@ import im.vector.app.databinding.ActivityRoomDetailBinding import im.vector.app.features.analytics.plan.Screen import im.vector.app.features.analytics.screen.ScreenEvent import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker import im.vector.app.features.matrixto.MatrixToBottomSheet import im.vector.app.features.navigation.Navigator @@ -97,17 +98,17 @@ class RoomDetailActivity : super.onCreate(savedInstanceState) supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, false) waitingView = views.waitingView.waitingView - val roomDetailArgs: RoomDetailArgs? = if (intent?.action == ACTION_ROOM_DETAILS_FROM_SHORTCUT) { - RoomDetailArgs(roomId = intent?.extras?.getString(EXTRA_ROOM_ID)!!) + val timelineArgs: TimelineArgs? = if (intent?.action == ACTION_ROOM_DETAILS_FROM_SHORTCUT) { + TimelineArgs(roomId = intent?.extras?.getString(EXTRA_ROOM_ID)!!) } else { intent?.extras?.getParcelable(EXTRA_ROOM_DETAIL_ARGS) } - if (roomDetailArgs == null) return - intent.putExtra(Mavericks.KEY_ARG, roomDetailArgs) - currentRoomId = roomDetailArgs.roomId + if (timelineArgs == null) return + intent.putExtra(Mavericks.KEY_ARG, timelineArgs) + currentRoomId = timelineArgs.roomId if (isFirstCreation()) { - replaceFragment(views.roomDetailContainer, RoomDetailFragment::class.java, roomDetailArgs) + replaceFragment(views.roomDetailContainer, TimelineFragment::class.java, timelineArgs) replaceFragment(views.roomDetailDrawerContainer, BreadcrumbsFragment::class.java) } @@ -145,7 +146,7 @@ class RoomDetailActivity : if (currentRoomId != switchToRoom.roomId) { currentRoomId = switchToRoom.roomId requireActiveMembershipViewModel.handle(RequireActiveMembershipAction.ChangeRoom(switchToRoom.roomId)) - replaceFragment(views.roomDetailContainer, RoomDetailFragment::class.java, RoomDetailArgs(switchToRoom.roomId)) + replaceFragment(views.roomDetailContainer, TimelineFragment::class.java, TimelineArgs(switchToRoom.roomId)) } } @@ -196,9 +197,9 @@ class RoomDetailActivity : const val EXTRA_ROOM_ID = "EXTRA_ROOM_ID" const val ACTION_ROOM_DETAILS_FROM_SHORTCUT = "ROOM_DETAILS_FROM_SHORTCUT" - fun newIntent(context: Context, roomDetailArgs: RoomDetailArgs): Intent { + fun newIntent(context: Context, timelineArgs: TimelineArgs): Intent { return Intent(context, RoomDetailActivity::class.java).apply { - putExtra(EXTRA_ROOM_DETAIL_ARGS, roomDetailArgs) + putExtra(EXTRA_ROOM_DETAIL_ARGS, timelineArgs) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt index d963030013..c63085f647 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewState.kt @@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.initsync.SyncStatusService @@ -26,6 +27,7 @@ import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.sync.SyncState +import org.matrix.android.sdk.api.session.threads.ThreadNotificationBadgeState import org.matrix.android.sdk.api.session.widgets.model.Widget import org.matrix.android.sdk.api.session.widgets.model.WidgetType @@ -67,15 +69,18 @@ data class RoomDetailViewState( val isAllowedToSetupEncryption: Boolean = true, val hasFailedSending: Boolean = false, val jitsiState: JitsiState = JitsiState(), - val switchToParentSpace: Boolean = false + val switchToParentSpace: Boolean = false, + val rootThreadEventId: String? = null, + val threadNotificationBadgeState: ThreadNotificationBadgeState = ThreadNotificationBadgeState() ) : MavericksState { - constructor(args: RoomDetailArgs) : this( + constructor(args: TimelineArgs) : this( roomId = args.roomId, eventId = args.eventId, // Also highlight the target event, if any highlightedEventId = args.eventId, - switchToParentSpace = args.switchToParentSpace + switchToParentSpace = args.switchToParentSpace, + rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId ) fun isWebRTCCallOptionAvailable() = (asyncRoomSummary.invoke()?.joinedMembersCount ?: 0) <= 2 @@ -85,4 +90,6 @@ data class RoomDetailViewState( fun hasActiveJitsiWidget() = activeRoomWidgets()?.any { it.type == WidgetType.Jitsi && it.isActive }.orFalse() fun isDm() = asyncRoomSummary()?.isDirect == true + + fun isThreadTimeline() = rootThreadEventId != null } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt index 6b5ed3ba66..193dc42f33 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/StartCallActionsHandler.kt @@ -32,7 +32,7 @@ class StartCallActionsHandler( private val fragment: Fragment, private val callManager: WebRtcCallManager, private val vectorPreferences: VectorPreferences, - private val roomDetailViewModel: RoomDetailViewModel, + private val timelineViewModel: TimelineViewModel, private val startCallActivityResultLauncher: ActivityResultLauncher>, private val showDialogWithMessage: (String) -> Unit, private val onTapToReturnToCall: () -> Unit) { @@ -45,7 +45,7 @@ class StartCallActionsHandler( handleCallRequest(false) } - private fun handleCallRequest(isVideoCall: Boolean) = withState(roomDetailViewModel) { state -> + private fun handleCallRequest(isVideoCall: Boolean) = withState(timelineViewModel) { state -> val roomSummary = state.asyncRoomSummary.invoke() ?: return@withState when (roomSummary.joinedMembersCount) { 1 -> { @@ -95,7 +95,7 @@ class StartCallActionsHandler( .setMessage(R.string.audio_video_meeting_description) .setPositiveButton(fragment.getString(R.string.create)) { _, _ -> // create the widget, then navigate to it.. - roomDetailViewModel.handle(RoomDetailAction.AddJitsiWidget(isVideoCall)) + timelineViewModel.handle(RoomDetailAction.AddJitsiWidget(isVideoCall)) } .setNegativeButton(fragment.getString(R.string.action_cancel), null) .show() @@ -121,22 +121,22 @@ class StartCallActionsHandler( private fun safeStartCall2(isVideoCall: Boolean) { val startCallAction = RoomDetailAction.StartCall(isVideoCall) - roomDetailViewModel.pendingAction = startCallAction + timelineViewModel.pendingAction = startCallAction if (isVideoCall) { if (checkPermissions(PERMISSIONS_FOR_VIDEO_IP_CALL, fragment.requireActivity(), startCallActivityResultLauncher, R.string.permissions_rationale_msg_camera_and_audio)) { - roomDetailViewModel.pendingAction = null - roomDetailViewModel.handle(startCallAction) + timelineViewModel.pendingAction = null + timelineViewModel.handle(startCallAction) } } else { if (checkPermissions(PERMISSIONS_FOR_AUDIO_IP_CALL, fragment.requireActivity(), startCallActivityResultLauncher, R.string.permissions_rationale_msg_record_audio)) { - roomDetailViewModel.pendingAction = null - roomDetailViewModel.handle(startCallAction) + timelineViewModel.pendingAction = null + timelineViewModel.handle(startCallAction) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt similarity index 82% rename from vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt rename to vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index d219f45815..9a309d58e0 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -25,7 +25,6 @@ import android.graphics.Typeface import android.net.Uri import android.os.Build import android.os.Bundle -import android.os.Parcelable import android.text.Spannable import android.text.format.DateUtils import android.view.HapticFeedbackConstants @@ -37,12 +36,14 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo +import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat import androidx.core.net.toUri import androidx.core.text.buildSpannedString import androidx.core.text.toSpannable @@ -116,7 +117,7 @@ import im.vector.app.core.utils.shareText import im.vector.app.core.utils.startInstallFromSourceIntent import im.vector.app.core.utils.toast import im.vector.app.databinding.DialogReportContentBinding -import im.vector.app.databinding.FragmentRoomDetailBinding +import im.vector.app.databinding.FragmentTimelineBinding import im.vector.app.features.analytics.plan.Click import im.vector.app.features.analytics.plan.Screen import im.vector.app.features.attachments.AttachmentTypeSelectorView @@ -136,6 +137,7 @@ import im.vector.app.features.command.Command import im.vector.app.features.crypto.keysbackup.restore.KeysBackupRestoreActivity import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.composer.CanSendStatus import im.vector.app.features.home.room.detail.composer.MessageComposerAction import im.vector.app.features.home.room.detail.composer.MessageComposerView @@ -167,6 +169,7 @@ import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet import im.vector.app.features.home.room.detail.views.RoomDetailLazyLoadedViews import im.vector.app.features.home.room.detail.widget.RoomWidgetsBottomSheet +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.html.PillImageSpan import im.vector.app.features.html.PillsPostProcessor @@ -200,7 +203,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.parcelize.Parcelize import nl.dionsegijn.konfetti.models.Shape import nl.dionsegijn.konfetti.models.Size import org.billcarsonfr.jsonviewer.JSonViewerDialog @@ -240,16 +242,7 @@ import java.net.URL import java.util.UUID import javax.inject.Inject -@Parcelize -data class RoomDetailArgs( - val roomId: String, - val eventId: String? = null, - val sharedData: SharedData? = null, - val openShareSpaceForId: String? = null, - val switchToParentSpace: Boolean = false -) : Parcelable - -class RoomDetailFragment @Inject constructor( +class TimelineFragment @Inject constructor( private val session: Session, private val avatarRenderer: AvatarRenderer, private val timelineEventController: TimelineEventController, @@ -269,7 +262,7 @@ class RoomDetailFragment @Inject constructor( private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker, private val clock: Clock ) : - VectorBaseFragment(), + VectorBaseFragment(), TimelineEventController.Callback, VectorInviteView.Callback, AttachmentTypeSelectorView.Callback, @@ -297,31 +290,34 @@ class RoomDetailFragment @Inject constructor( private val galleryOrCameraDialogHelper = GalleryOrCameraDialogHelper(this, colorProvider) - private val roomDetailArgs: RoomDetailArgs by args() + private val timelineArgs: TimelineArgs by args() private val glideRequests by lazy { GlideApp.with(this) } private val pillsPostProcessor by lazy { - pillsPostProcessorFactory.create(roomDetailArgs.roomId) + pillsPostProcessorFactory.create(timelineArgs.roomId) } private val autoCompleter: AutoCompleter by lazy { - autoCompleterFactory.create(roomDetailArgs.roomId) + autoCompleterFactory.create(timelineArgs.roomId, isThreadTimeLine()) } - private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel() + + private val timelineViewModel: TimelineViewModel by fragmentViewModel() private val messageComposerViewModel: MessageComposerViewModel by fragmentViewModel() private val debouncer = Debouncer(createUIHandler()) private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback - override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRoomDetailBinding { - return FragmentRoomDetailBinding.inflate(inflater, container, false) + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentTimelineBinding { + return FragmentTimelineBinding.inflate(inflater, container, false) } override fun getMenuRes() = R.menu.menu_timeline private lateinit var sharedActionViewModel: MessageSharedActionViewModel + private lateinit var sharedActivityActionViewModel: RoomDetailSharedActionViewModel + private lateinit var knownCallsViewModel: SharedKnownCallsViewModel private lateinit var layoutManager: LinearLayoutManager @@ -347,7 +343,7 @@ class RoomDetailFragment @Inject constructor( analyticsScreenName = Screen.ScreenName.Room setFragmentResultListener(MigrateRoomBottomSheet.REQUEST_KEY) { _, bundle -> bundle.getString(MigrateRoomBottomSheet.BUNDLE_KEY_REPLACEMENT_ROOM)?.let { replacementRoomId -> - roomDetailViewModel.handle(RoomDetailAction.RoomUpgradeSuccess(replacementRoomId)) + timelineViewModel.handle(RoomDetailAction.RoomUpgradeSuccess(replacementRoomId)) } } } @@ -356,13 +352,14 @@ class RoomDetailFragment @Inject constructor( lifecycle.addObserver(ConferenceEventObserver(vectorBaseActivity, this::onBroadcastJitsiEvent)) super.onViewCreated(view, savedInstanceState) sharedActionViewModel = activityViewModelProvider.get(MessageSharedActionViewModel::class.java) + sharedActivityActionViewModel = activityViewModelProvider.get(RoomDetailSharedActionViewModel::class.java) knownCallsViewModel = activityViewModelProvider.get(SharedKnownCallsViewModel::class.java) attachmentsHelper = AttachmentsHelper(requireContext(), this).register() callActionsHandler = StartCallActionsHandler( - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, fragment = this, vectorPreferences = vectorPreferences, - roomDetailViewModel = roomDetailViewModel, + timelineViewModel = timelineViewModel, callManager = callManager, startCallActivityResultLauncher = startCallActivityResultLauncher, showDialogWithMessage = ::showDialogWithMessage, @@ -382,8 +379,8 @@ class RoomDetailFragment @Inject constructor( setupRemoveJitsiWidgetView() setupVoiceMessageView() - views.roomToolbarContentView.debouncedClicks { - navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId) + views.includeRoomToolbar.roomToolbarContentView.debouncedClicks { + navigator.openRoomProfile(requireActivity(), timelineArgs.roomId) } sharedActionViewModel @@ -400,7 +397,7 @@ class RoomDetailFragment @Inject constructor( invalidateOptionsMenu() } - roomDetailViewModel.onEach(RoomDetailViewState::canShowJumpToReadMarker, RoomDetailViewState::unreadState) { _, _ -> + timelineViewModel.onEach(RoomDetailViewState::canShowJumpToReadMarker, RoomDetailViewState::unreadState) { _, _ -> updateJumpToReadMarkerViewVisibility() } @@ -417,7 +414,7 @@ class RoomDetailFragment @Inject constructor( } } - roomDetailViewModel.onEach( + timelineViewModel.onEach( RoomDetailViewState::syncState, RoomDetailViewState::incrementalSyncStatus, RoomDetailViewState::pushCounter @@ -447,7 +444,7 @@ class RoomDetailFragment @Inject constructor( }.exhaustive } - roomDetailViewModel.observeViewEvents { + timelineViewModel.observeViewEvents { when (it) { is RoomDetailViewEvents.Failure -> showErrorInSnackbar(it.throwable) is RoomDetailViewEvents.OnNewTimelineEvents -> scrollOnNewMessageCallback.addNewTimelineEventIds(it.eventIds) @@ -470,7 +467,7 @@ class RoomDetailFragment @Inject constructor( RoomDetailViewEvents.HideWaitingView -> vectorBaseActivity.hideWaitingView() is RoomDetailViewEvents.RequestNativeWidgetPermission -> requestNativeWidgetPermission(it) is RoomDetailViewEvents.OpenRoom -> handleOpenRoom(it) - RoomDetailViewEvents.OpenInvitePeople -> navigator.openInviteUsersToRoom(requireContext(), roomDetailArgs.roomId) + RoomDetailViewEvents.OpenInvitePeople -> navigator.openInviteUsersToRoom(requireContext(), timelineArgs.roomId) RoomDetailViewEvents.OpenSetRoomAvatarDialog -> galleryOrCameraDialogHelper.show() RoomDetailViewEvents.OpenRoomSettings -> handleOpenRoomSettings(RoomProfileActivity.EXTRA_DIRECT_ACCESS_ROOM_SETTINGS) RoomDetailViewEvents.OpenRoomProfile -> handleOpenRoomSettings() @@ -513,12 +510,12 @@ class RoomDetailFragment @Inject constructor( private fun setupRemoveJitsiWidgetView() { views.removeJitsiWidgetView.onCompleteSliding = { - withState(roomDetailViewModel) { + withState(timelineViewModel) { val jitsiWidgetId = it.jitsiState.widgetId ?: return@withState if (it.jitsiState.hasJoined) { leaveJitsiConference() } - roomDetailViewModel.handle(RoomDetailAction.RemoveWidget(jitsiWidgetId)) + timelineViewModel.handle(RoomDetailAction.RemoveWidget(jitsiWidgetId)) } } } @@ -528,7 +525,7 @@ class RoomDetailFragment @Inject constructor( } private fun onBroadcastJitsiEvent(conferenceEvent: ConferenceEvent) { - roomDetailViewModel.handle(RoomDetailAction.UpdateJoinJitsiCallStatus(conferenceEvent)) + timelineViewModel.handle(RoomDetailAction.UpdateJoinJitsiCallStatus(conferenceEvent)) } private fun onCannotRecord() { @@ -554,7 +551,7 @@ class RoomDetailFragment @Inject constructor( private fun handleShowRoomUpgradeDialog(roomDetailViewEvents: MessageComposerViewEvents.ShowRoomUpgradeDialog) { val tag = MigrateRoomBottomSheet::javaClass.name - MigrateRoomBottomSheet.newInstance(roomDetailArgs.roomId, roomDetailViewEvents.newVersion) + MigrateRoomBottomSheet.newInstance(timelineArgs.roomId, roomDetailViewEvents.newVersion) .show(parentFragmentManager, tag) } @@ -589,7 +586,7 @@ class RoomDetailFragment @Inject constructor( override fun onImageReady(uri: Uri?) { uri ?: return - roomDetailViewModel.handle( + timelineViewModel.handle( RoomDetailAction.SetAvatarAction( newAvatarUri = uri, newAvatarFileName = getFilenameFromUri(requireContext(), uri) ?: UUID.randomUUID().toString() @@ -600,7 +597,7 @@ class RoomDetailFragment @Inject constructor( private fun handleOpenRoomSettings(directAccess: Int? = null) { navigator.openRoomProfile( requireContext(), - roomDetailArgs.roomId, + timelineArgs.roomId, directAccess ) } @@ -617,7 +614,7 @@ class RoomDetailFragment @Inject constructor( navigator .openLocationSharing( context = requireContext(), - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, mode = LocationSharingMode.PREVIEW, initialLocationData = locationContent.toLocationData(), locationOwnerId = if (isGenericLocation) null else senderId @@ -634,13 +631,13 @@ class RoomDetailFragment @Inject constructor( WidgetArgs( baseUrl = it.domain, kind = WidgetKind.ROOM, - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, widgetId = it.widget.widgetId ) ).apply { directListener = { granted -> if (granted) { - roomDetailViewModel.handle(RoomDetailAction.EnsureNativeWidgetAllowed( + timelineViewModel.handle(RoomDetailAction.EnsureNativeWidgetAllowed( widget = it.widget, userJustAccepted = true, grantedEvents = it.grantedEvents @@ -660,7 +657,7 @@ class RoomDetailFragment @Inject constructor( navigator.openIntegrationManager( context = requireContext(), activityResultLauncher = integrationManagerActivityResultLauncher, - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, integId = null, screen = screen ) @@ -720,13 +717,13 @@ class RoomDetailFragment @Inject constructor( .setMessage(getString(R.string.event_status_delete_all_failed_dialog_message)) .setNegativeButton(R.string.no, null) .setPositiveButton(R.string.yes) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.RemoveAllFailedMessages) + timelineViewModel.handle(RoomDetailAction.RemoveAllFailedMessages) } .show() } override fun onRetryClicked() { - roomDetailViewModel.handle(RoomDetailAction.ResendAll) + timelineViewModel.handle(RoomDetailAction.ResendAll) } } } @@ -748,7 +745,7 @@ class RoomDetailFragment @Inject constructor( } override fun onVoiceRecordingCancelled() { - messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true)) + messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true, rootThreadEventId = getRootThreadEventId())) vibrate(requireContext()) updateRecordingUiState(RecordingUiState.Idle) } @@ -764,37 +761,42 @@ class RoomDetailFragment @Inject constructor( } override fun onSendVoiceMessage() { - messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = false)) + messageComposerViewModel.handle( + MessageComposerAction.EndRecordingVoiceMessage(isCancelled = false, rootThreadEventId = getRootThreadEventId())) updateRecordingUiState(RecordingUiState.Idle) } override fun onDeleteVoiceMessage() { - messageComposerViewModel.handle(MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true)) + messageComposerViewModel.handle( + MessageComposerAction.EndRecordingVoiceMessage(isCancelled = true, rootThreadEventId = getRootThreadEventId())) updateRecordingUiState(RecordingUiState.Idle) } override fun onRecordingLimitReached() { - messageComposerViewModel.handle(MessageComposerAction.PauseRecordingVoiceMessage) + messageComposerViewModel.handle( + MessageComposerAction.PauseRecordingVoiceMessage) updateRecordingUiState(RecordingUiState.Draft) } override fun onRecordingWaveformClicked() { - messageComposerViewModel.handle(MessageComposerAction.PauseRecordingVoiceMessage) + messageComposerViewModel.handle( + MessageComposerAction.PauseRecordingVoiceMessage) updateRecordingUiState(RecordingUiState.Draft) } private fun updateRecordingUiState(state: RecordingUiState) { - messageComposerViewModel.handle(MessageComposerAction.OnVoiceRecordingUiStateChanged(state)) + messageComposerViewModel.handle( + MessageComposerAction.OnVoiceRecordingUiStateChanged(state)) } } } private fun joinJitsiRoom(jitsiWidget: Widget, enableVideo: Boolean) { - navigator.openRoomWidget(requireContext(), roomDetailArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo)) + navigator.openRoomWidget(requireContext(), timelineArgs.roomId, jitsiWidget, mapOf(JitsiCallViewModel.ENABLE_VIDEO_OPTION to enableVideo)) } private fun openStickerPicker(event: RoomDetailViewEvents.OpenStickerPicker) { - navigator.openStickerPicker(requireContext(), stickerActivityResultLauncher, roomDetailArgs.roomId, event.widget) + navigator.openStickerPicker(requireContext(), stickerActivityResultLauncher, timelineArgs.roomId, event.widget) } private fun startOpenFileIntent(action: RoomDetailViewEvents.OpenFile) { @@ -818,7 +820,7 @@ class RoomDetailFragment @Inject constructor( val safeContext = context ?: return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (!safeContext.packageManager.canRequestPackageInstalls()) { - roomDetailViewModel.pendingEvent = action + timelineViewModel.pendingEvent = action startInstallFromSourceIntent(safeContext, installApkActivityResultLauncher) } else { openFile(action) @@ -830,7 +832,7 @@ class RoomDetailFragment @Inject constructor( private val installApkActivityResultLauncher = registerStartForActivityResult { activityResult -> if (activityResult.resultCode == Activity.RESULT_OK) { - roomDetailViewModel.pendingEvent?.let { + timelineViewModel.pendingEvent?.let { if (it is RoomDetailViewEvents.OpenFile) { openFile(it) } @@ -838,7 +840,7 @@ class RoomDetailFragment @Inject constructor( } else { // User cancelled } - roomDetailViewModel.pendingEvent = null + timelineViewModel.pendingEvent = null } private fun displayPromptForIntegrationManager() { @@ -864,7 +866,7 @@ class RoomDetailFragment @Inject constructor( } private fun handleShareData() { - when (val sharedData = roomDetailArgs.sharedData) { + when (val sharedData = timelineArgs.sharedData) { is SharedData.Text -> { messageComposerViewModel.handle(MessageComposerAction.EnterRegularMode(sharedData.text, fromSharing = true)) } @@ -877,7 +879,7 @@ class RoomDetailFragment @Inject constructor( } private fun handleSpaceShare() { - roomDetailArgs.openShareSpaceForId?.let { spaceId -> + timelineArgs.openShareSpaceForId?.let { spaceId -> ShareSpaceBottomSheet.show(childFragmentManager, spaceId, true) view?.post { handleChatEffect(ChatEffect.CONFETTI) @@ -898,18 +900,18 @@ class RoomDetailFragment @Inject constructor( } override fun onDestroy() { - roomDetailViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState) + timelineViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState) super.onDestroy() } private fun setupJumpToBottomView() { views.jumpToBottomView.visibility = View.INVISIBLE views.jumpToBottomView.debouncedClicks { - roomDetailViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState) + timelineViewModel.handle(RoomDetailAction.ExitTrackingUnreadMessagesState) views.jumpToBottomView.visibility = View.INVISIBLE - if (!roomDetailViewModel.timeline.isLive) { + if (!timelineViewModel.timeline.isLive) { scrollOnNewMessageCallback.forceScrollOnNextUpdate() - roomDetailViewModel.timeline.restartWithEventId(null) + timelineViewModel.timeline.restartWithEventId(null) } else { layoutManager.scrollToPosition(0) } @@ -928,7 +930,7 @@ class RoomDetailFragment @Inject constructor( onJumpToReadMarkerClicked() } views.jumpToReadMarkerView.setOnCloseIconClickListener { - roomDetailViewModel.handle(RoomDetailAction.MarkAllAsRead) + timelineViewModel.handle(RoomDetailAction.MarkAllAsRead) } } @@ -971,11 +973,11 @@ class RoomDetailFragment @Inject constructor( private fun setupNotificationView() { views.notificationAreaView.delegate = object : NotificationAreaView.Delegate { override fun onTombstoneEventClicked() { - roomDetailViewModel.handle(RoomDetailAction.JoinAndOpenReplacementRoom) + timelineViewModel.handle(RoomDetailAction.JoinAndOpenReplacementRoom) } override fun onMisconfiguredEncryptionClicked() { - roomDetailViewModel.handle(RoomDetailAction.OnClickMisconfiguredEncryption) + timelineViewModel.handle(RoomDetailAction.OnClickMisconfiguredEncryption) } } } @@ -990,15 +992,23 @@ class RoomDetailFragment @Inject constructor( } val joinConfItem = menu.findItem(R.id.join_conference) (joinConfItem.actionView as? JoinConferenceView)?.onJoinClicked = { - roomDetailViewModel.handle(RoomDetailAction.JoinJitsiCall) + timelineViewModel.handle(RoomDetailAction.JoinJitsiCall) + } + + // Custom thread notification menu item + menu.findItem(R.id.menu_timeline_thread_list)?.let { menuItem -> + menuItem.actionView.setOnClickListener { + onOptionsItemSelected(menuItem) + } } } override fun onPrepareOptionsMenu(menu: Menu) { menu.forEach { - it.isVisible = roomDetailViewModel.isMenuItemVisible(it.itemId) + it.isVisible = timelineViewModel.isMenuItemVisible(it.itemId) } - withState(roomDetailViewModel) { state -> + + withState(timelineViewModel) { state -> // Set the visual state of the call buttons (voice/video) to enabled/disabled according to user permissions val hasCallInRoom = callManager.getCallsByRoomId(state.roomId).isNotEmpty() || state.jitsiState.hasJoined val callButtonsEnabled = !hasCallInRoom && when (state.asyncRoomSummary.invoke()?.joinedMembersCount) { @@ -1029,46 +1039,114 @@ class RoomDetailFragment @Inject constructor( actionView.findViewById(R.id.cart_badge).setTextOrHide("$widgetsCount") matrixAppsMenuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) } + + // Handle custom threads badge notification + updateMenuThreadNotificationBadge(menu, state) } } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { - R.id.invite -> { - navigator.openInviteUsersToRoom(requireActivity(), roomDetailArgs.roomId) + R.id.invite -> { + navigator.openInviteUsersToRoom(requireActivity(), timelineArgs.roomId) true } - R.id.timeline_setting -> { - navigator.openRoomProfile(requireActivity(), roomDetailArgs.roomId) + R.id.timeline_setting -> { + navigator.openRoomProfile(requireActivity(), timelineArgs.roomId) true } - R.id.open_matrix_apps -> { - roomDetailViewModel.handle(RoomDetailAction.ManageIntegrations) + R.id.open_matrix_apps -> { + timelineViewModel.handle(RoomDetailAction.ManageIntegrations) true } - R.id.voice_call -> { + R.id.voice_call -> { callActionsHandler.onVoiceCallClicked() true } - R.id.video_call -> { + R.id.video_call -> { callActionsHandler.onVideoCallClicked() true } - R.id.search -> { + R.id.menu_timeline_thread_list -> { + navigateToThreadList() + true + } + R.id.search -> { handleSearchAction() true } - R.id.dev_tools -> { - navigator.openDevTools(requireContext(), roomDetailArgs.roomId) + R.id.dev_tools -> { + navigator.openDevTools(requireContext(), timelineArgs.roomId) true } - else -> super.onOptionsItemSelected(item) + R.id.menu_thread_timeline_copy_link -> { + getRootThreadEventId()?.let { + val permalink = session.permalinkService().createPermalink(timelineArgs.roomId, it) + copyToClipboard(requireContext(), permalink, false) + showSnackWithMessage(getString(R.string.copied_to_clipboard)) + } + true + } + R.id.menu_thread_timeline_view_in_room -> { + handleViewInRoomAction() + true + } + R.id.menu_thread_timeline_share -> { + getRootThreadEventId()?.let { + val permalink = session.permalinkService().createPermalink(timelineArgs.roomId, it) + shareText(requireContext(), permalink) + } + true + } + else -> super.onOptionsItemSelected(item) + } + } + + /** + * Update menu thread notification badge appropriately + */ + private fun updateMenuThreadNotificationBadge(menu: Menu, state: RoomDetailViewState) { + val menuThreadList = menu.findItem(R.id.menu_timeline_thread_list).actionView + val badgeFrameLayout = menuThreadList.findViewById(R.id.threadNotificationBadgeFrameLayout) + val badgeTextView = menuThreadList.findViewById(R.id.threadNotificationBadgeTextView) + + val unreadThreadMessages = state.threadNotificationBadgeState.numberOfLocalUnreadThreads + val userIsMentioned = state.threadNotificationBadgeState.isUserMentioned + + if (unreadThreadMessages > 0) { + badgeFrameLayout.isVisible = true + badgeTextView.text = unreadThreadMessages.toString() + val badgeDrawable = DrawableCompat.wrap(badgeFrameLayout.background) + val color = ContextCompat.getColor(requireContext(), if (userIsMentioned) R.color.palette_vermilion else R.color.palette_gray_200) + DrawableCompat.setTint(badgeDrawable, color) + badgeFrameLayout.background = badgeDrawable + } else { + badgeFrameLayout.isVisible = false + } + } + + /** + * View and highlight the original root thread message in the main timeline + */ + private fun handleViewInRoomAction() { + getRootThreadEventId()?.let { + val newRoom = timelineArgs.copy(threadTimelineArgs = null, eventId = it) + context?.let { con -> + val int = RoomDetailActivity.newIntent(con, newRoom) + int.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK + con.startActivity(int) + } } } private fun handleSearchAction() { - if (session.getRoom(roomDetailArgs.roomId)?.isEncrypted() == false) { - navigator.openSearch(requireContext(), roomDetailArgs.roomId) + if (session.getRoom(timelineArgs.roomId)?.isEncrypted() == false) { + navigator.openSearch( + context = requireContext(), + roomId = timelineArgs.roomId, + roomDisplayName = timelineViewModel.getRoomSummary()?.displayName, + roomAvatarUrl = timelineViewModel.getRoomSummary()?.avatarUrl + ) } else { showDialogWithMessage(getString(R.string.search_is_not_supported_in_e2e_room)) } @@ -1151,7 +1229,7 @@ class RoomDetailFragment @Inject constructor( override fun onResume() { super.onResume() - notificationDrawerManager.setCurrentRoom(roomDetailArgs.roomId) + notificationDrawerManager.setCurrentRoom(timelineArgs.roomId) roomDetailPendingActionStore.data?.let { handlePendingAction(it) } roomDetailPendingActionStore.data = null @@ -1162,11 +1240,11 @@ class RoomDetailFragment @Inject constructor( private fun handlePendingAction(roomDetailPendingAction: RoomDetailPendingAction) { when (roomDetailPendingAction) { is RoomDetailPendingAction.JumpToReadReceipt -> - roomDetailViewModel.handle(RoomDetailAction.JumpToReadReceipt(roomDetailPendingAction.userId)) + timelineViewModel.handle(RoomDetailAction.JumpToReadReceipt(roomDetailPendingAction.userId)) is RoomDetailPendingAction.MentionUser -> insertUserDisplayNameInTextEditor(roomDetailPendingAction.userId) is RoomDetailPendingAction.OpenOrCreateDm -> - roomDetailViewModel.handle(RoomDetailAction.OpenOrCreateDm(roomDetailPendingAction.userId)) + timelineViewModel.handle(RoomDetailAction.OpenOrCreateDm(roomDetailPendingAction.userId)) is RoomDetailPendingAction.OpenRoom -> handleOpenRoom(RoomDetailViewEvents.OpenRoom(roomDetailPendingAction.roomId, roomDetailPendingAction.closeCurrentRoom)) }.exhaustive @@ -1219,7 +1297,7 @@ class RoomDetailFragment @Inject constructor( if (activityResult.resultCode == Activity.RESULT_OK) { val sendData = AttachmentsPreviewActivity.getOutput(data) val keepOriginalSize = AttachmentsPreviewActivity.getKeepOriginalSize(data) - roomDetailViewModel.handle(RoomDetailAction.SendMedia(sendData, !keepOriginalSize)) + timelineViewModel.handle(RoomDetailAction.SendMedia(sendData, !keepOriginalSize)) } } @@ -1228,7 +1306,7 @@ class RoomDetailFragment @Inject constructor( val eventId = EmojiReactionPickerActivity.getOutputEventId(activityResult.data) val reaction = EmojiReactionPickerActivity.getOutputReaction(activityResult.data) if (eventId != null && reaction != null) { - roomDetailViewModel.handle(RoomDetailAction.SendReaction(eventId, reaction)) + timelineViewModel.handle(RoomDetailAction.SendReaction(eventId, reaction)) } } } @@ -1238,16 +1316,16 @@ class RoomDetailFragment @Inject constructor( if (activityResult.resultCode == Activity.RESULT_OK) { WidgetActivity.getOutput(data).toModel() ?.let { content -> - roomDetailViewModel.handle(RoomDetailAction.SendSticker(content)) + timelineViewModel.handle(RoomDetailAction.SendSticker(content)) } } } private val startCallActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> if (allGranted) { - (roomDetailViewModel.pendingAction as? RoomDetailAction.StartCall)?.let { - roomDetailViewModel.pendingAction = null - roomDetailViewModel.handle(it) + (timelineViewModel.pendingAction as? RoomDetailAction.StartCall)?.let { + timelineViewModel.pendingAction = null + timelineViewModel.handle(it) } } else { if (deniedPermanently) { @@ -1261,7 +1339,7 @@ class RoomDetailFragment @Inject constructor( private fun setupRecyclerView() { timelineEventController.callback = this - timelineEventController.timeline = roomDetailViewModel.timeline + timelineEventController.timeline = timelineViewModel.timeline views.timelineRecyclerView.trackItemsVisibilityChange() layoutManager = object : LinearLayoutManager(context, RecyclerView.VERTICAL, true) { @@ -1329,8 +1407,9 @@ class RoomDetailFragment @Inject constructor( } private fun updateJumpToReadMarkerViewVisibility() { + if (isThreadTimeLine()) return viewLifecycleOwner.lifecycleScope.launchWhenResumed { - val state = roomDetailViewModel.awaitState() + val state = timelineViewModel.awaitState() val showJumpToUnreadBanner = when (state.unreadState) { UnreadState.Unknown, UnreadState.HasNoUnread -> false @@ -1388,8 +1467,12 @@ class RoomDetailFragment @Inject constructor( views.composerLayout.callback = object : MessageComposerView.Callback { override fun onAddAttachment() { if (!::attachmentTypeSelector.isInitialized) { - attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@RoomDetailFragment) - attachmentTypeSelector.setAttachmentVisibility(AttachmentTypeSelectorView.Type.LOCATION, vectorPreferences.isLocationSharingEnabled()) + attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@TimelineFragment) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentTypeSelectorView.Type.LOCATION, + vectorPreferences.isLocationSharingEnabled()) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentTypeSelectorView.Type.POLL, !isThreadTimeLine()) } attachmentTypeSelector.show(views.composerLayout.views.attachmentButton) } @@ -1428,6 +1511,7 @@ class RoomDetailFragment @Inject constructor( } private fun observerUserTyping() { + if (isThreadTimeLine()) return views.composerLayout.views.composerEditText.textChanges() .skipInitialValue() .debounce(300) @@ -1440,7 +1524,7 @@ class RoomDetailFragment @Inject constructor( views.composerLayout.views.composerEditText.focusChanges() .onEach { - roomDetailViewModel.handle(RoomDetailAction.ComposerFocusChange(it)) + timelineViewModel.handle(RoomDetailAction.ComposerFocusChange(it)) } .launchIn(viewLifecycleOwner.lifecycleScope) } @@ -1454,7 +1538,7 @@ class RoomDetailFragment @Inject constructor( return isHandled } - override fun invalidate() = withState(roomDetailViewModel, messageComposerViewModel) { mainState, messageComposerState -> + override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> invalidateOptionsMenu() val summary = mainState.asyncRoomSummary() renderToolbar(summary, mainState.formattedTypingUsers) @@ -1497,7 +1581,7 @@ class RoomDetailFragment @Inject constructor( } else if (summary?.membership == Membership.INVITE && inviter != null) { views.hideComposerViews() lazyLoadedViews.inviteView(true)?.apply { - callback = this@RoomDetailFragment + callback = this@TimelineFragment isVisible = true render(inviter, VectorInviteView.Mode.LARGE, mainState.changeMembershipState) setOnClickListener(null) @@ -1508,29 +1592,43 @@ class RoomDetailFragment @Inject constructor( } } - private fun FragmentRoomDetailBinding.hideComposerViews() { + private fun FragmentTimelineBinding.hideComposerViews() { composerLayout.isVisible = false voiceMessageRecorderView.isVisible = false } private fun renderToolbar(roomSummary: RoomSummary?, typingMessage: String?) { - if (roomSummary == null) { - views.roomToolbarContentView.isClickable = false + if (!isThreadTimeLine()) { + views.includeRoomToolbar.roomToolbarContentView.isVisible = true + views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = false + if (roomSummary == null) { + views.includeRoomToolbar.roomToolbarContentView.isClickable = false + } else { + views.includeRoomToolbar.roomToolbarContentView.isClickable = roomSummary.membership == Membership.JOIN + views.includeRoomToolbar.roomToolbarTitleView.text = roomSummary.displayName + avatarRenderer.render(roomSummary.toMatrixItem(), views.includeRoomToolbar.roomToolbarAvatarImageView) + renderSubTitle(typingMessage, roomSummary.topic) + views.includeRoomToolbar.roomToolbarDecorationImageView.render(roomSummary.roomEncryptionTrustLevel) + views.includeRoomToolbar.roomToolbarPresenceImageView.render(roomSummary.isDirect, roomSummary.directUserPresence) + views.includeRoomToolbar.roomToolbarPublicImageView.isVisible = roomSummary.isPublic && !roomSummary.isDirect + } } else { - views.roomToolbarContentView.isClickable = roomSummary.membership == Membership.JOIN - views.roomToolbarTitleView.text = roomSummary.displayName - avatarRenderer.render(roomSummary.toMatrixItem(), views.roomToolbarAvatarImageView) - renderSubTitle(typingMessage, roomSummary.topic) - views.roomToolbarDecorationImageView.render(roomSummary.roomEncryptionTrustLevel) - views.roomToolbarPresenceImageView.render(roomSummary.isDirect, roomSummary.directUserPresence) - views.roomToolbarPublicImageView.isVisible = roomSummary.isPublic && !roomSummary.isDirect + views.includeRoomToolbar.roomToolbarContentView.isVisible = false + views.includeThreadToolbar.roomToolbarThreadConstraintLayout.isVisible = true + timelineArgs.threadTimelineArgs?.let { + val matrixItem = MatrixItem.RoomItem(it.roomId, it.displayName, it.avatarUrl) + avatarRenderer.render(matrixItem, views.includeThreadToolbar.roomToolbarThreadImageView) + views.includeThreadToolbar.roomToolbarThreadShieldImageView.render(it.roomEncryptionTrustLevel) + views.includeThreadToolbar.roomToolbarThreadSubtitleTextView.text = it.displayName + } + views.includeThreadToolbar.roomToolbarThreadTitleTextView.text = resources.getText(R.string.thread_timeline_title) } } private fun renderSubTitle(typingMessage: String?, topic: String) { // TODO Temporary place to put typing data val subtitle = typingMessage?.takeIf { it.isNotBlank() } ?: topic - views.roomToolbarSubtitleView.apply { + views.includeRoomToolbar.roomToolbarSubtitleView.apply { setTextOrHide(subtitle) if (typingMessage.isNullOrBlank()) { setTextColor(colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary)) @@ -1544,27 +1642,30 @@ class RoomDetailFragment @Inject constructor( private fun renderSendMessageResult(sendMessageResult: MessageComposerViewEvents.SendMessageResult) { when (sendMessageResult) { - is MessageComposerViewEvents.SlashCommandLoading -> { + is MessageComposerViewEvents.SlashCommandLoading -> { showLoading(null) } - is MessageComposerViewEvents.SlashCommandError -> { + is MessageComposerViewEvents.SlashCommandError -> { displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command)) } - is MessageComposerViewEvents.SlashCommandUnknown -> { + is MessageComposerViewEvents.SlashCommandUnknown -> { displayCommandError(getString(R.string.unrecognized_command, sendMessageResult.command)) } - is MessageComposerViewEvents.SlashCommandResultOk -> { + is MessageComposerViewEvents.SlashCommandResultOk -> { dismissLoadingDialog() views.composerLayout.setTextIfDifferent("") sendMessageResult.messageRes?.let { showSnackWithMessage(getString(it)) } } - is MessageComposerViewEvents.SlashCommandResultError -> { + is MessageComposerViewEvents.SlashCommandResultError -> { dismissLoadingDialog() displayCommandError(errorFormatter.toHumanReadable(sendMessageResult.throwable)) } - is MessageComposerViewEvents.SlashCommandNotImplemented -> { + is MessageComposerViewEvents.SlashCommandNotImplemented -> { displayCommandError(getString(R.string.not_implemented)) } + is MessageComposerViewEvents.SlashCommandNotSupportedInThreads -> { + displayCommandError(getString(R.string.command_not_supported_in_threads, sendMessageResult.command.command)) + } } // .exhaustive lockSendButton = false @@ -1602,7 +1703,7 @@ class RoomDetailFragment @Inject constructor( .setView(layout) .setPositiveButton(R.string.report_content_custom_submit) { _, _ -> val reason = views.dialogReportContentInput.text.toString() - roomDetailViewModel.handle(RoomDetailAction.ReportContent(action.eventId, action.senderId, reason)) + timelineViewModel.handle(RoomDetailAction.ReportContent(action.eventId, action.senderId, reason)) } .setNegativeButton(R.string.action_cancel, null) .show() @@ -1618,7 +1719,7 @@ class RoomDetailFragment @Inject constructor( reasonHintRes = R.string.delete_event_dialog_reason_hint, titleRes = action.dialogTitleRes ) { reason -> - roomDetailViewModel.handle(RoomDetailAction.RedactAction(action.eventId, reason)) + timelineViewModel.handle(RoomDetailAction.RedactAction(action.eventId, reason)) } } @@ -1640,7 +1741,7 @@ class RoomDetailFragment @Inject constructor( .setMessage(R.string.content_reported_as_spam_content) .setPositiveButton(R.string.ok, null) .setNegativeButton(R.string.block_user) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId)) + timelineViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId)) } .show() } @@ -1650,7 +1751,7 @@ class RoomDetailFragment @Inject constructor( .setMessage(R.string.content_reported_as_inappropriate_content) .setPositiveButton(R.string.ok, null) .setNegativeButton(R.string.block_user) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId)) + timelineViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId)) } .show() } @@ -1660,7 +1761,7 @@ class RoomDetailFragment @Inject constructor( .setMessage(R.string.content_reported_content) .setPositiveButton(R.string.ok, null) .setNegativeButton(R.string.block_user) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId)) + timelineViewModel.handle(RoomDetailAction.IgnoreUser(data.senderId)) } .show() } @@ -1669,14 +1770,14 @@ class RoomDetailFragment @Inject constructor( is RoomDetailAction.RequestVerification -> { Timber.v("## SAS RequestVerification action") VerificationBottomSheet.withArgs( - roomDetailArgs.roomId, + timelineArgs.roomId, data.userId ).show(parentFragmentManager, "REQ") } is RoomDetailAction.AcceptVerificationRequest -> { Timber.v("## SAS AcceptVerificationRequest action") VerificationBottomSheet.withArgs( - roomDetailArgs.roomId, + timelineArgs.roomId, data.otherUserId, data.transactionId ).show(parentFragmentManager, "REQ") @@ -1687,7 +1788,7 @@ class RoomDetailFragment @Inject constructor( setArguments(VerificationBottomSheet.VerificationArgs( otherUserId = otherUserId, verificationId = data.transactionId, - roomId = roomDetailArgs.roomId + roomId = timelineArgs.roomId )) }.show(parentFragmentManager, "REQ") } @@ -1695,25 +1796,39 @@ class RoomDetailFragment @Inject constructor( } // TimelineEventController.Callback ************************************************************ - override fun onUrlClicked(url: String, title: String): Boolean { viewLifecycleOwner.lifecycleScope.launch { val isManaged = permalinkHandler .launch(requireActivity(), url, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?, rootThreadEventId: String?): Boolean { // Same room? - if (roomId == roomDetailArgs.roomId) { - // Navigation to same room - if (eventId == null) { + if (roomId != timelineArgs.roomId) return false + // Navigation to same room + if (!isThreadTimeLine()) { + if (rootThreadEventId != null) { + // Thread link, so PermalinkHandler will handle the navigation + return false + } + return if (eventId == null) { showSnackWithMessage(getString(R.string.navigate_to_room_when_already_in_the_room)) + true } else { // Highlight and scroll to this event - roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(eventId, true)) + timelineViewModel.handle(RoomDetailAction.NavigateToEvent(eventId, true)) + true + } + } else { + return if (rootThreadEventId == getRootThreadEventId() && eventId == null) { + showSnackWithMessage(getString(R.string.navigate_to_thread_when_already_in_the_thread)) + true + } else if (rootThreadEventId == getRootThreadEventId() && eventId != null) { + // we are in the same thread + timelineViewModel.handle(RoomDetailAction.NavigateToEvent(eventId, true)) + true + } else { + false } - return true } - // Not handled - return false } override fun navToMemberProfile(userId: String, deepLink: Uri): Boolean { @@ -1755,11 +1870,11 @@ class RoomDetailFragment @Inject constructor( } override fun onEventVisible(event: TimelineEvent) { - roomDetailViewModel.handle(RoomDetailAction.TimelineEventTurnsVisible(event)) + timelineViewModel.handle(RoomDetailAction.TimelineEventTurnsVisible(event)) } override fun onEventInvisible(event: TimelineEvent) { - roomDetailViewModel.handle(RoomDetailAction.TimelineEventTurnsInvisible(event)) + timelineViewModel.handle(RoomDetailAction.TimelineEventTurnsInvisible(event)) } override fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) { @@ -1769,7 +1884,7 @@ class RoomDetailFragment @Inject constructor( override fun onImageMessageClicked(messageImageContent: MessageImageInfoContent, mediaData: ImageContentRenderer.Data, view: View) { navigator.openMediaViewer( activity = requireActivity(), - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, mediaData = mediaData, view = view ) { pairs -> @@ -1781,7 +1896,7 @@ class RoomDetailFragment @Inject constructor( override fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) { navigator.openMediaViewer( activity = requireActivity(), - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, mediaData = mediaData, view = view ) { pairs -> @@ -1790,60 +1905,47 @@ class RoomDetailFragment @Inject constructor( } } -// override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) { -// val isEncrypted = messageFileContent.encryptedFileInfo != null -// val action = RoomDetailAction.DownloadOrOpen(eventId, messageFileContent, isEncrypted) -// // We need WRITE_EXTERNAL permission -// // if (!isEncrypted || checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_DOWNLOAD_FILE)) { -// showSnackWithMessage(getString(R.string.downloading_file, messageFileContent.getFileName())) -// roomDetailViewModel.handle(action) -// // } else { -// // roomDetailViewModel.pendingAction = action -// // } -// } - private fun cleanUpAfterPermissionNotGranted() { // Reset all pending data - roomDetailViewModel.pendingAction = null + timelineViewModel.pendingAction = null attachmentsHelper.pendingType = null } -// override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) { -// vectorBaseActivity.notImplemented("open audio file") -// } - override fun onLoadMore(direction: Timeline.Direction) { - roomDetailViewModel.handle(RoomDetailAction.LoadMoreTimelineEvents(direction)) + timelineViewModel.handle(RoomDetailAction.LoadMoreTimelineEvents(direction)) } - override fun onEventCellClicked(informationData: MessageInformationData, messageContent: Any?, view: View) { + override fun onEventCellClicked(informationData: MessageInformationData, messageContent: Any?, view: View, isRootThreadEvent: Boolean) { when (messageContent) { is MessageVerificationRequestContent -> { - roomDetailViewModel.handle(RoomDetailAction.ResumeVerification(informationData.eventId, null)) + timelineViewModel.handle(RoomDetailAction.ResumeVerification(informationData.eventId, null)) } is MessageWithAttachmentContent -> { val action = RoomDetailAction.DownloadOrOpen(informationData.eventId, informationData.senderId, messageContent) - roomDetailViewModel.handle(action) + timelineViewModel.handle(action) } is EncryptedEventContent -> { - roomDetailViewModel.handle(RoomDetailAction.TapOnFailedToDecrypt(informationData.eventId)) + timelineViewModel.handle(RoomDetailAction.TapOnFailedToDecrypt(informationData.eventId)) } is MessageLocationContent -> { handleShowLocationPreview(messageContent, informationData.senderId) } else -> { - Timber.d("No click action defined for this message content") + val handled = onThreadSummaryClicked(informationData.eventId, isRootThreadEvent) + if (!handled) { + Timber.d("No click action defined for this message content") + } } } } override fun onEventLongClicked(informationData: MessageInformationData, messageContent: Any?, view: View): Boolean { view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) - val roomId = roomDetailArgs.roomId + val roomId = timelineArgs.roomId this.view?.hideKeyboard() MessageActionsBottomSheet - .newInstance(roomId, informationData) + .newInstance(roomId, informationData, isThreadTimeLine()) .show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS") return true @@ -1851,26 +1953,35 @@ class RoomDetailFragment @Inject constructor( private fun handleCancelSend(action: EventSharedAction.Cancel) { if (action.force) { - roomDetailViewModel.handle(RoomDetailAction.CancelSend(action.eventId, true)) + timelineViewModel.handle(RoomDetailAction.CancelSend(action.eventId, true)) } else { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.dialog_title_confirmation) .setMessage(getString(R.string.event_status_cancel_sending_dialog_message)) .setNegativeButton(R.string.no, null) .setPositiveButton(R.string.yes) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.CancelSend(action.eventId, false)) + timelineViewModel.handle(RoomDetailAction.CancelSend(action.eventId, false)) } .show() } } + override fun onThreadSummaryClicked(eventId: String, isRootThreadEvent: Boolean): Boolean { + return if (vectorPreferences.areThreadMessagesEnabled() && isRootThreadEvent && !isThreadTimeLine()) { + navigateToThreadTimeline(eventId) + true + } else { + false + } + } + override fun onAvatarClicked(informationData: MessageInformationData) { // roomDetailViewModel.handle(RoomDetailAction.RequestVerification(informationData.userId)) openRoomMemberProfile(informationData.senderId) } private fun openRoomMemberProfile(userId: String) { - navigator.openRoomMemberProfile(userId = userId, roomId = roomDetailArgs.roomId, context = requireActivity()) + navigator.openRoomMemberProfile(userId = userId, roomId = timelineArgs.roomId, context = requireActivity()) } override fun onMemberNameClicked(informationData: MessageInformationData) { @@ -1880,36 +1991,36 @@ class RoomDetailFragment @Inject constructor( override fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean) { if (on) { // we should test the current real state of reaction on this event - roomDetailViewModel.handle(RoomDetailAction.SendReaction(informationData.eventId, reaction)) + timelineViewModel.handle(RoomDetailAction.SendReaction(informationData.eventId, reaction)) } else { // I need to redact a reaction - roomDetailViewModel.handle(RoomDetailAction.UndoReaction(informationData.eventId, reaction)) + timelineViewModel.handle(RoomDetailAction.UndoReaction(informationData.eventId, reaction)) } } override fun onLongClickOnReactionPill(informationData: MessageInformationData, reaction: String) { - ViewReactionsBottomSheet.newInstance(roomDetailArgs.roomId, informationData) + ViewReactionsBottomSheet.newInstance(timelineArgs.roomId, informationData) .show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS") } override fun onEditedDecorationClicked(informationData: MessageInformationData) { - ViewEditHistoryBottomSheet.newInstance(roomDetailArgs.roomId, informationData) + ViewEditHistoryBottomSheet.newInstance(timelineArgs.roomId, informationData) .show(requireActivity().supportFragmentManager, "DISPLAY_EDITS") } override fun onTimelineItemAction(itemAction: RoomDetailAction) { - roomDetailViewModel.handle(itemAction) + timelineViewModel.handle(itemAction) } override fun getPreviewUrlRetriever(): PreviewUrlRetriever { - return roomDetailViewModel.previewUrlRetriever + return timelineViewModel.previewUrlRetriever } override fun onRoomCreateLinkClicked(url: String) { viewLifecycleOwner.lifecycleScope.launchWhenResumed { permalinkHandler .launch(requireContext(), url, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?, rootThreadEventId: String?): Boolean { requireActivity().finish() return false } @@ -1923,7 +2034,7 @@ class RoomDetailFragment @Inject constructor( } override fun onReadMarkerVisible() { - roomDetailViewModel.handle(RoomDetailAction.EnterTrackingUnreadMessagesState) + timelineViewModel.handle(RoomDetailAction.EnterTrackingUnreadMessagesState) } override fun onPreviewUrlClicked(url: String) { @@ -1931,7 +2042,7 @@ class RoomDetailFragment @Inject constructor( } override fun onPreviewUrlCloseClicked(eventId: String, url: String) { - roomDetailViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, url)) + timelineViewModel.handle(RoomDetailAction.DoNotShowPreviewUrlFor(eventId, url)) } override fun onPreviewUrlImageClicked(sharedView: View?, mxcUrl: String?, title: String?) { @@ -2011,7 +2122,7 @@ class RoomDetailFragment @Inject constructor( emojiActivityResultLauncher.launch(EmojiReactionPickerActivity.intent(requireContext(), action.eventId)) } is EventSharedAction.ViewReactions -> { - ViewReactionsBottomSheet.newInstance(roomDetailArgs.roomId, action.messageInformationData) + ViewReactionsBottomSheet.newInstance(timelineArgs.roomId, action.messageInformationData) .show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS") } is EventSharedAction.Copy -> { @@ -2047,11 +2158,11 @@ class RoomDetailFragment @Inject constructor( } is EventSharedAction.QuickReact -> { // eventId,ClickedOn,Add - roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add)) + timelineViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add)) } is EventSharedAction.Edit -> { if (action.eventType == EventType.POLL_START) { - navigator.openCreatePoll(requireContext(), roomDetailArgs.roomId, action.eventId, PollMode.EDIT) + navigator.openCreatePoll(requireContext(), timelineArgs.roomId, action.eventId, PollMode.EDIT) } else if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString())) } else { @@ -2068,26 +2179,40 @@ class RoomDetailFragment @Inject constructor( requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) } } + is EventSharedAction.ReplyInThread -> { + if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { + navigateToThreadTimeline(action.eventId) + } else { + requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) + } + } + is EventSharedAction.ViewInRoom -> { + if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { + handleViewInRoomAction() + } else { + requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) + } + } is EventSharedAction.CopyPermalink -> { - val permalink = session.permalinkService().createPermalink(roomDetailArgs.roomId, action.eventId) + val permalink = session.permalinkService().createPermalink(timelineArgs.roomId, action.eventId) copyToClipboard(requireContext(), permalink, false) showSnackWithMessage(getString(R.string.copied_to_clipboard)) } is EventSharedAction.Resend -> { - roomDetailViewModel.handle(RoomDetailAction.ResendMessage(action.eventId)) + timelineViewModel.handle(RoomDetailAction.ResendMessage(action.eventId)) } is EventSharedAction.Remove -> { - roomDetailViewModel.handle(RoomDetailAction.RemoveFailedEcho(action.eventId)) + timelineViewModel.handle(RoomDetailAction.RemoveFailedEcho(action.eventId)) } is EventSharedAction.Cancel -> { handleCancelSend(action) } is EventSharedAction.ReportContentSpam -> { - roomDetailViewModel.handle(RoomDetailAction.ReportContent( + timelineViewModel.handle(RoomDetailAction.ReportContent( action.eventId, action.senderId, "This message is spam", spam = true)) } is EventSharedAction.ReportContentInappropriate -> { - roomDetailViewModel.handle(RoomDetailAction.ReportContent( + timelineViewModel.handle(RoomDetailAction.ReportContent( action.eventId, action.senderId, "This message is inappropriate", inappropriate = true)) } is EventSharedAction.ReportContentCustom -> { @@ -2103,7 +2228,7 @@ class RoomDetailFragment @Inject constructor( onUrlLongClicked(action.url) } is EventSharedAction.ReRequestKey -> { - roomDetailViewModel.handle(RoomDetailAction.ReRequestKeys(action.eventId)) + timelineViewModel.handle(RoomDetailAction.ReRequestKeys(action.eventId)) } is EventSharedAction.UseKeyBackup -> { context?.let { @@ -2122,7 +2247,7 @@ class RoomDetailFragment @Inject constructor( .setMessage(R.string.end_poll_confirmation_description) .setNegativeButton(R.string.action_cancel, null) .setPositiveButton(R.string.end_poll_confirmation_approve_button) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.EndPoll(eventId)) + timelineViewModel.handle(RoomDetailAction.EndPoll(eventId)) } .show() } @@ -2133,7 +2258,7 @@ class RoomDetailFragment @Inject constructor( .setMessage(R.string.room_participants_action_ignore_prompt_msg) .setNegativeButton(R.string.action_cancel, null) .setPositiveButton(R.string.room_participants_action_ignore) { _, _ -> - roomDetailViewModel.handle(RoomDetailAction.IgnoreUser(senderId)) + timelineViewModel.handle(RoomDetailAction.IgnoreUser(senderId)) } .show() } @@ -2153,7 +2278,7 @@ class RoomDetailFragment @Inject constructor( views.composerLayout.views.composerEditText.setText(Command.EMOTE.command + " ") views.composerLayout.views.composerEditText.setSelection(Command.EMOTE.command.length + 1) } else { - val roomMember = roomDetailViewModel.getMember(userId) + val roomMember = timelineViewModel.getMember(userId) // TODO move logic outside of fragment (roomMember?.displayName ?: userId) .let { sanitizeDisplayName(it) } @@ -2206,29 +2331,60 @@ class RoomDetailFragment @Inject constructor( .show() } -// VectorInviteView.Callback + /** + * Navigate to Threads timeline for the specified rootThreadEventId + * using the ThreadsActivity + */ + private fun navigateToThreadTimeline(rootThreadEventId: String) { + context?.let { + val roomThreadDetailArgs = ThreadTimelineArgs( + roomId = timelineArgs.roomId, + displayName = timelineViewModel.getRoomSummary()?.displayName, + avatarUrl = timelineViewModel.getRoomSummary()?.avatarUrl, + roomEncryptionTrustLevel = timelineViewModel.getRoomSummary()?.roomEncryptionTrustLevel, + rootThreadEventId = rootThreadEventId) + navigator.openThread(it, roomThreadDetailArgs) + } + } + + /** + * Navigate to Threads list for the current room + * using the ThreadsActivity + */ + + private fun navigateToThreadList() { + context?.let { + val roomThreadDetailArgs = ThreadTimelineArgs( + roomId = timelineArgs.roomId, + displayName = timelineViewModel.getRoomSummary()?.displayName, + roomEncryptionTrustLevel = timelineViewModel.getRoomSummary()?.roomEncryptionTrustLevel, + avatarUrl = timelineViewModel.getRoomSummary()?.avatarUrl) + navigator.openThreadList(it, roomThreadDetailArgs) + } + } + + // VectorInviteView.Callback override fun onAcceptInvite() { - notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) } - roomDetailViewModel.handle(RoomDetailAction.AcceptInvite) + notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(timelineArgs.roomId) } + timelineViewModel.handle(RoomDetailAction.AcceptInvite) } override fun onRejectInvite() { - notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(roomDetailArgs.roomId) } - roomDetailViewModel.handle(RoomDetailAction.RejectInvite) + notificationDrawerManager.updateEvents { it.clearMemberShipNotificationForRoom(timelineArgs.roomId) } + timelineViewModel.handle(RoomDetailAction.RejectInvite) } - private fun onJumpToReadMarkerClicked() = withState(roomDetailViewModel) { + private fun onJumpToReadMarkerClicked() = withState(timelineViewModel) { if (it.unreadState is UnreadState.HasUnread) { - roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.firstUnreadEventId, false)) + timelineViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.firstUnreadEventId, false)) } if (it.unreadState is UnreadState.ReadMarkerNotLoaded) { - roomDetailViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.readMarkerId, false)) + timelineViewModel.handle(RoomDetailAction.NavigateToEvent(it.unreadState.readMarkerId, false)) } } -// AttachmentTypeSelectorView.Callback - + // AttachmentTypeSelectorView.Callback private val typeSelectedActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> if (allGranted) { val pendingType = attachmentsHelper.pendingType @@ -2263,13 +2419,13 @@ class RoomDetailFragment @Inject constructor( AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) - AttachmentTypeSelectorView.Type.STICKER -> roomDetailViewModel.handle(RoomDetailAction.SelectStickerAttachment) - AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), roomDetailArgs.roomId, null, PollMode.CREATE) + AttachmentTypeSelectorView.Type.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment) + AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), timelineArgs.roomId, null, PollMode.CREATE) AttachmentTypeSelectorView.Type.LOCATION -> { navigator .openLocationSharing( context = requireContext(), - roomId = roomDetailArgs.roomId, + roomId = timelineArgs.roomId, mode = LocationSharingMode.STATIC_SHARING, initialLocationData = null, locationOwnerId = session.myUserId @@ -2278,13 +2434,12 @@ class RoomDetailFragment @Inject constructor( }.exhaustive } -// AttachmentsHelper.Callback - + // AttachmentsHelper.Callback override fun onContentAttachmentsReady(attachments: List) { val grouped = attachments.toGroupedContentAttachmentData() if (grouped.notPreviewables.isNotEmpty()) { // Send the not previewable attachments right now (?) - roomDetailViewModel.handle(RoomDetailAction.SendMedia(grouped.notPreviewables, false)) + timelineViewModel.handle(RoomDetailAction.SendMedia(grouped.notPreviewables, false)) } if (grouped.previewables.isNotEmpty()) { val intent = AttachmentsPreviewActivity.newIntent(requireContext(), AttachmentsPreviewArgs(grouped.previewables)) @@ -2322,4 +2477,14 @@ class RoomDetailFragment @Inject constructor( } } } + + /** + * Returns true if the current room is a Thread room, false otherwise + */ + private fun isThreadTimeLine(): Boolean = timelineArgs.threadTimelineArgs?.rootThreadEventId != null + + /** + * Returns the root thread event if we are in a thread room, otherwise returns null + */ + fun getRootThreadEventId(): String? = timelineArgs.threadTimelineArgs?.rootThreadEventId } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt similarity index 92% rename from vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt rename to vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 9149ae1dca..cc3dabe16b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -78,6 +78,7 @@ import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.LocalEcho +import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isTextMessage import org.matrix.android.sdk.api.session.events.model.toContent @@ -90,11 +91,14 @@ import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.message.getFileUrl +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent import org.matrix.android.sdk.api.session.room.model.tombstone.RoomTombstoneContent import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.read.ReadService import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.threads.ThreadNotificationBadgeState +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState import org.matrix.android.sdk.api.session.widgets.model.WidgetType import org.matrix.android.sdk.api.util.toOptional import org.matrix.android.sdk.flow.flow @@ -103,7 +107,7 @@ import org.matrix.android.sdk.internal.crypto.model.event.WithHeldCode import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean -class RoomDetailViewModel @AssistedInject constructor( +class TimelineViewModel @AssistedInject constructor( @Assisted private val initialState: RoomDetailViewState, private val vectorPreferences: VectorPreferences, private val vectorDataStore: VectorDataStore, @@ -129,7 +133,7 @@ class RoomDetailViewModel @AssistedInject constructor( private val invisibleEventsSource = BehaviorDataSource() private val visibleEventsSource = BehaviorDataSource() private var timelineEvents = MutableSharedFlow>(0) - val timeline = timelineFactory.createTimeline(viewModelScope, room, eventId) + val timeline = timelineFactory.createTimeline(viewModelScope, room, eventId, initialState.rootThreadEventId) // Same lifecycle than the ViewModel (survive to screen rotation) val previewUrlRetriever = PreviewUrlRetriever(session, viewModelScope) @@ -146,16 +150,16 @@ class RoomDetailViewModel @AssistedInject constructor( private var prepareToEncrypt: Async = Uninitialized @AssistedFactory - interface Factory : MavericksAssistedViewModelFactory { - override fun create(initialState: RoomDetailViewState): RoomDetailViewModel + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: RoomDetailViewState): TimelineViewModel } - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { const val PAGINATION_COUNT = 50 } init { - timeline.start() + timeline.start(initialState.rootThreadEventId) timeline.addListener(this) observeRoomSummary() observeMembershipChanges() @@ -203,6 +207,17 @@ class RoomDetailViewModel @AssistedInject constructor( } } } + + // Threads + initThreads() + } + + /** + * Threads specific initialization + */ + private fun initThreads() { + markThreadTimelineAsReadLocal() + observeLocalThreadNotifications() } private fun observeDataStore() { @@ -316,8 +331,40 @@ class RoomDetailViewModel @AssistedInject constructor( .launchIn(viewModelScope) } + /** + * Mark the thread as read, while the user navigated within the thread + * This is a local implementation has nothing to do with APIs + */ + private fun markThreadTimelineAsReadLocal() { + initialState.rootThreadEventId?.let { + session.coroutineScope.launch { + room.markThreadAsRead(it) + } + } + } + + /** + * Observe local unread threads + */ + private fun observeLocalThreadNotifications() { + room.flow() + .liveLocalUnreadThreadList() + .execute { + val threadList = it.invoke() + val isUserMentioned = threadList?.firstOrNull { threadRootEvent -> + threadRootEvent.root.threadDetails?.threadNotificationState == ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE + }?.let { true } ?: false + val numberOfLocalUnreadThreads = threadList?.size ?: 0 + copy(threadNotificationBadgeState = ThreadNotificationBadgeState( + numberOfLocalUnreadThreads = numberOfLocalUnreadThreads, + isUserMentioned = isUserMentioned)) + } + } + fun getOtherUserIds() = room.roomSummary()?.otherMemberIds + fun getRoomSummary() = room.roomSummary() + override fun handle(action: RoomDetailAction) { when (action) { is RoomDetailAction.ComposerFocusChange -> handleComposerFocusChange(action) @@ -463,7 +510,11 @@ class RoomDetailViewModel @AssistedInject constructor( } private fun handleSendSticker(action: RoomDetailAction.SendSticker) { - room.sendEvent(EventType.STICKER, action.stickerContent.toContent()) + val content = initialState.rootThreadEventId?.let { + action.stickerContent.copy(relatesTo = RelationDefaultContent(RelationType.IO_THREAD, it)) + } ?: action.stickerContent + + room.sendEvent(EventType.STICKER, content.toContent()) } private fun handleStartCall(action: RoomDetailAction.StartCall) { @@ -650,20 +701,30 @@ class RoomDetailViewModel @AssistedInject constructor( private fun isIntegrationEnabled() = session.integrationManagerService().isIntegrationEnabled() fun isMenuItemVisible(@IdRes itemId: Int): Boolean = com.airbnb.mvrx.withState(this) { state -> + if (state.asyncRoomSummary()?.membership != Membership.JOIN) { return@withState false } - when (itemId) { - R.id.timeline_setting -> true - R.id.invite -> state.canInvite - R.id.open_matrix_apps -> true - R.id.voice_call -> state.isWebRTCCallOptionAvailable() - R.id.video_call -> state.isWebRTCCallOptionAvailable() || state.jitsiState.confId == null || state.jitsiState.hasJoined - // Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^ - R.id.join_conference -> !state.isWebRTCCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined - R.id.search -> true - R.id.dev_tools -> vectorPreferences.developerMode() - else -> false + + if (initialState.isThreadTimeline()) { + when (itemId) { + R.id.menu_thread_timeline_more -> true + else -> false + } + } else { + when (itemId) { + R.id.timeline_setting -> true + R.id.invite -> state.canInvite + R.id.open_matrix_apps -> true + R.id.voice_call -> state.isWebRTCCallOptionAvailable() + R.id.video_call -> state.isWebRTCCallOptionAvailable() || state.jitsiState.confId == null || state.jitsiState.hasJoined + // Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^ + R.id.join_conference -> !state.isWebRTCCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined + R.id.search -> true + R.id.menu_timeline_thread_list -> vectorPreferences.areThreadMessagesEnabled() + R.id.dev_tools -> vectorPreferences.developerMode() + else -> false + } } } @@ -691,7 +752,12 @@ class RoomDetailViewModel @AssistedInject constructor( } private fun handleSendMedia(action: RoomDetailAction.SendMedia) { - room.sendMedias(action.attachments, action.compressBeforeSending, emptySet()) + room.sendMedias( + action.attachments, + action.compressBeforeSending, + emptySet(), + initialState.rootThreadEventId + ) } private fun handleEventVisible(action: RoomDetailAction.TimelineEventTurnsVisible) { @@ -1128,6 +1194,9 @@ class RoomDetailViewModel @AssistedInject constructor( chatEffectManager.delegate = null chatEffectManager.dispose() callManager.removeProtocolsCheckerListener(this) + // we should also mark it as read here, for the scenario that the user + // is already in the thread timeline + markThreadTimelineAsReadLocal() super.onCleared() } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt new file mode 100644 index 0000000000..f22fe1b7df --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/arguments/TimelineArgs.kt @@ -0,0 +1,32 @@ +/* + * 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 im.vector.app.features.home.room.detail.arguments + +import android.os.Parcelable +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs +import im.vector.app.features.share.SharedData +import kotlinx.parcelize.Parcelize + +@Parcelize +data class TimelineArgs( + val roomId: String, + val eventId: String? = null, + val sharedData: SharedData? = null, + val openShareSpaceForId: String? = null, + val threadTimelineArgs: ThreadTimelineArgs? = null, + val switchToParentSpace: Boolean = false +) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index 690f127cbd..10cef39942 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt @@ -35,7 +35,7 @@ sealed class MessageComposerAction : VectorViewModelAction { data class InitializeVoiceRecorder(val attachmentData: ContentAttachmentData) : MessageComposerAction() data class OnVoiceRecordingUiStateChanged(val uiState: VoiceMessageRecorderView.RecordingUiState) : MessageComposerAction() object StartRecordingVoiceMessage : MessageComposerAction() - data class EndRecordingVoiceMessage(val isCancelled: Boolean) : MessageComposerAction() + data class EndRecordingVoiceMessage(val isCancelled: Boolean, val rootThreadEventId: String?) : MessageComposerAction() object PauseRecordingVoiceMessage : MessageComposerAction() data class PlayOrPauseVoicePlayback(val eventId: String, val messageAudioContent: MessageAudioContent) : MessageComposerAction() object PlayOrPauseRecordingPlayback : MessageComposerAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt index 07c58c9196..c1af838795 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewEvents.kt @@ -32,6 +32,8 @@ sealed class MessageComposerViewEvents : VectorViewEvents { data class JoinRoomCommandSuccess(val roomId: String) : SendMessageResult() class SlashCommandError(val command: Command) : SendMessageResult() class SlashCommandUnknown(val command: String) : SendMessageResult() + class SlashCommandNotSupportedInThreads(val command: Command) : SendMessageResult() + data class SlashCommandHandled(@StringRes val messageRes: Int? = null) : SendMessageResult() object SlashCommandLoading : SendMessageResult() data class SlashCommandResultOk(@StringRes val messageRes: Int? = null) : SendMessageResult() class SlashCommandResultError(val throwable: Throwable) : SendMessageResult() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index a2d9b50edd..0d90227168 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -46,6 +46,8 @@ import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.content.ContentAttachmentData 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.isThread 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 @@ -53,6 +55,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomAvatarContent import org.matrix.android.sdk.api.session.room.model.RoomEncryptionAlgorithm import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.send.UserDraft import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent @@ -98,7 +101,7 @@ class MessageComposerViewModel @AssistedInject constructor( is MessageComposerAction.OnTextChanged -> handleOnTextChanged(action) is MessageComposerAction.OnVoiceRecordingUiStateChanged -> handleOnVoiceRecordingUiStateChanged(action) is MessageComposerAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage() - is MessageComposerAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled) + is MessageComposerAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled, action.rootThreadEventId) is MessageComposerAction.PlayOrPauseVoicePlayback -> handlePlayOrPauseVoicePlayback(action) MessageComposerAction.PauseRecordingVoiceMessage -> handlePauseRecordingVoiceMessage() MessageComposerAction.PlayOrPauseRecordingPlayback -> handlePlayOrPauseRecordingPlayback() @@ -187,135 +190,185 @@ class MessageComposerViewModel @AssistedInject constructor( withState { state -> when (state.sendMode) { is SendMode.Regular -> { - when (val slashCommandResult = commandParser.parseSlashCommand(action.text)) { - is ParsedCommand.ErrorNotACommand -> { + when (val slashCommandResult = commandParser.parseSlashCommand( + textMessage = action.text, + isInThreadTimeline = state.isInThreadTimeline())) { + is ParsedCommand.ErrorNotACommand -> { // Send the text message to the room - room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) + if (state.rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = action.text, + autoMarkdown = action.autoMarkdown) + } else { + room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown) + } + _viewEvents.post(MessageComposerViewEvents.MessageSent) popDraft() } - is ParsedCommand.ErrorSyntax -> { + is ParsedCommand.ErrorSyntax -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandError(slashCommandResult.command)) } - is ParsedCommand.ErrorEmptySlashCommand -> { + is ParsedCommand.ErrorEmptySlashCommand -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandUnknown("/")) } - is ParsedCommand.ErrorUnknownSlashCommand -> { + is ParsedCommand.ErrorUnknownSlashCommand -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandUnknown(slashCommandResult.slashCommand)) } - is ParsedCommand.SendPlainText -> { + is ParsedCommand.ErrorCommandNotSupportedInThreads -> { + _viewEvents.post(MessageComposerViewEvents.SlashCommandNotSupportedInThreads(slashCommandResult.command)) + } + is ParsedCommand.SendPlainText -> { // Send the text message to the room, without markdown - room.sendTextMessage(slashCommandResult.message, autoMarkdown = false) + if (state.rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = slashCommandResult.message, + autoMarkdown = false) + } else { + room.sendTextMessage(slashCommandResult.message, autoMarkdown = false) + } _viewEvents.post(MessageComposerViewEvents.MessageSent) popDraft() } - is ParsedCommand.ChangeRoomName -> { + is ParsedCommand.ChangeRoomName -> { handleChangeRoomNameSlashCommand(slashCommandResult) } - is ParsedCommand.Invite -> { + is ParsedCommand.Invite -> { handleInviteSlashCommand(slashCommandResult) } - is ParsedCommand.Invite3Pid -> { + is ParsedCommand.Invite3Pid -> { handleInvite3pidSlashCommand(slashCommandResult) } - is ParsedCommand.SetUserPowerLevel -> { + is ParsedCommand.SetUserPowerLevel -> { handleSetUserPowerLevel(slashCommandResult) } - is ParsedCommand.ClearScalarToken -> { + is ParsedCommand.ClearScalarToken -> { // TODO _viewEvents.post(MessageComposerViewEvents.SlashCommandNotImplemented) } - is ParsedCommand.SetMarkdown -> { + is ParsedCommand.SetMarkdown -> { vectorPreferences.setMarkdownEnabled(slashCommandResult.enable) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk( if (slashCommandResult.enable) R.string.markdown_has_been_enabled else R.string.markdown_has_been_disabled)) popDraft() } - is ParsedCommand.BanUser -> { + is ParsedCommand.BanUser -> { handleBanSlashCommand(slashCommandResult) } - is ParsedCommand.UnbanUser -> { + is ParsedCommand.UnbanUser -> { handleUnbanSlashCommand(slashCommandResult) } - is ParsedCommand.IgnoreUser -> { + is ParsedCommand.IgnoreUser -> { handleIgnoreSlashCommand(slashCommandResult) } - is ParsedCommand.UnignoreUser -> { + is ParsedCommand.UnignoreUser -> { handleUnignoreSlashCommand(slashCommandResult) } - is ParsedCommand.RemoveUser -> { + is ParsedCommand.RemoveUser -> { handleRemoveSlashCommand(slashCommandResult) } - is ParsedCommand.JoinRoom -> { + is ParsedCommand.JoinRoom -> { handleJoinToAnotherRoomSlashCommand(slashCommandResult) popDraft() } - is ParsedCommand.PartRoom -> { + is ParsedCommand.PartRoom -> { handlePartSlashCommand(slashCommandResult) } - is ParsedCommand.SendEmote -> { - room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE, autoMarkdown = action.autoMarkdown) - _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) - popDraft() - } - is ParsedCommand.SendRainbow -> { - slashCommandResult.message.toString().let { - room.sendFormattedTextMessage(it, rainbowGenerator.generate(it)) + is ParsedCommand.SendEmote -> { + if (state.rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = slashCommandResult.message, + msgType = MessageType.MSGTYPE_EMOTE, + autoMarkdown = action.autoMarkdown) + } else { + room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE, autoMarkdown = action.autoMarkdown) } _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) popDraft() } - is ParsedCommand.SendRainbowEmote -> { - slashCommandResult.message.toString().let { - room.sendFormattedTextMessage(it, rainbowGenerator.generate(it), MessageType.MSGTYPE_EMOTE) + is ParsedCommand.SendRainbow -> { + val message = slashCommandResult.message.toString() + if (state.rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = slashCommandResult.message, + formattedText = rainbowGenerator.generate(message)) + } else { + room.sendFormattedTextMessage(message, rainbowGenerator.generate(message)) } _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) popDraft() } - is ParsedCommand.SendSpoiler -> { - room.sendFormattedTextMessage( - "[${stringProvider.getString(R.string.spoiler)}](${slashCommandResult.message})", - "${slashCommandResult.message}" - ) + is ParsedCommand.SendRainbowEmote -> { + val message = slashCommandResult.message.toString() + if (state.rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = slashCommandResult.message, + msgType = MessageType.MSGTYPE_EMOTE, + formattedText = rainbowGenerator.generate(message)) + } else { + room.sendFormattedTextMessage(message, rainbowGenerator.generate(message), MessageType.MSGTYPE_EMOTE) + } + _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) popDraft() } - is ParsedCommand.SendShrug -> { - sendPrefixedMessage("¯\\_(ツ)_/¯", slashCommandResult.message) + is ParsedCommand.SendSpoiler -> { + val text = "[${stringProvider.getString(R.string.spoiler)}](${slashCommandResult.message})" + val formattedText = "${slashCommandResult.message}" + if (state.rootThreadEventId != null) { + room.replyInThread( + rootThreadEventId = state.rootThreadEventId, + replyInThreadText = text, + formattedText = formattedText) + } else { + room.sendFormattedTextMessage( + text, + formattedText) + } _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) popDraft() } - is ParsedCommand.SendLenny -> { - sendPrefixedMessage("( ͡° ͜ʖ ͡°)", slashCommandResult.message) + is ParsedCommand.SendShrug -> { + sendPrefixedMessage("¯\\_(ツ)_/¯", slashCommandResult.message, state.rootThreadEventId) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) popDraft() } - is ParsedCommand.SendChatEffect -> { + is ParsedCommand.SendLenny -> { + sendPrefixedMessage("( ͡° ͜ʖ ͡°)", slashCommandResult.message, state.rootThreadEventId) + _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) + popDraft() + } + is ParsedCommand.SendChatEffect -> { sendChatEffect(slashCommandResult) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) popDraft() } - is ParsedCommand.ChangeTopic -> { + is ParsedCommand.ChangeTopic -> { handleChangeTopicSlashCommand(slashCommandResult) } - is ParsedCommand.ChangeDisplayName -> { + is ParsedCommand.ChangeDisplayName -> { handleChangeDisplayNameSlashCommand(slashCommandResult) } - is ParsedCommand.ChangeDisplayNameForRoom -> { + is ParsedCommand.ChangeDisplayNameForRoom -> { handleChangeDisplayNameForRoomSlashCommand(slashCommandResult) } - is ParsedCommand.ChangeRoomAvatar -> { + is ParsedCommand.ChangeRoomAvatar -> { handleChangeRoomAvatarSlashCommand(slashCommandResult) } - is ParsedCommand.ChangeAvatarForRoom -> { + is ParsedCommand.ChangeAvatarForRoom -> { handleChangeAvatarForRoomSlashCommand(slashCommandResult) } - is ParsedCommand.ShowUser -> { + is ParsedCommand.ShowUser -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) handleWhoisSlashCommand(slashCommandResult) popDraft() } - is ParsedCommand.DiscardSession -> { + is ParsedCommand.DiscardSession -> { if (room.isEncrypted()) { session.cryptoService().discardOutboundSession(room.roomId) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk()) @@ -328,7 +381,7 @@ class MessageComposerViewModel @AssistedInject constructor( ) } } - is ParsedCommand.CreateSpace -> { + is ParsedCommand.CreateSpace -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandLoading) viewModelScope.launch(Dispatchers.IO) { try { @@ -352,7 +405,7 @@ class MessageComposerViewModel @AssistedInject constructor( } Unit } - is ParsedCommand.AddToSpace -> { + is ParsedCommand.AddToSpace -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandLoading) viewModelScope.launch(Dispatchers.IO) { try { @@ -371,7 +424,7 @@ class MessageComposerViewModel @AssistedInject constructor( } Unit } - is ParsedCommand.JoinSpace -> { + is ParsedCommand.JoinSpace -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandLoading) viewModelScope.launch(Dispatchers.IO) { try { @@ -384,7 +437,7 @@ class MessageComposerViewModel @AssistedInject constructor( } Unit } - is ParsedCommand.LeaveRoom -> { + is ParsedCommand.LeaveRoom -> { viewModelScope.launch(Dispatchers.IO) { try { session.getRoom(slashCommandResult.roomId)?.leave(null) @@ -396,7 +449,7 @@ class MessageComposerViewModel @AssistedInject constructor( } Unit } - is ParsedCommand.UpgradeRoom -> { + is ParsedCommand.UpgradeRoom -> { _viewEvents.post( MessageComposerViewEvents.ShowRoomUpgradeDialog( slashCommandResult.newVersion, @@ -410,7 +463,20 @@ class MessageComposerViewModel @AssistedInject constructor( } is SendMode.Edit -> { // is original event a reply? - val inReplyTo = state.sendMode.timelineEvent.getRelationContent()?.inReplyTo?.eventId + val relationContent = state.sendMode.timelineEvent.getRelationContent() + val inReplyTo = if (state.rootThreadEventId != null) { + if (relationContent?.inReplyTo?.shouldRenderInThread() == true) { + // Reply within a thread event + relationContent.inReplyTo?.eventId + } else { + // Normal thread event + null + } + } else { + // Normal event + relationContent?.inReplyTo?.eventId + } + if (inReplyTo != null) { // TODO check if same content? room.getTimeLineEvent(inReplyTo)?.let { @@ -432,16 +498,34 @@ class MessageComposerViewModel @AssistedInject constructor( popDraft() } is SendMode.Quote -> { - room.sendQuotedTextMessage(state.sendMode.timelineEvent, action.text.toString(), action.autoMarkdown) + room.sendQuotedTextMessage( + quotedEvent = state.sendMode.timelineEvent, + text = action.text.toString(), + autoMarkdown = action.autoMarkdown, + rootThreadEventId = state.rootThreadEventId) _viewEvents.post(MessageComposerViewEvents.MessageSent) popDraft() } is SendMode.Reply -> { - state.sendMode.timelineEvent.let { - room.replyToMessage(it, action.text.toString(), action.autoMarkdown) - _viewEvents.post(MessageComposerViewEvents.MessageSent) - popDraft() - } + val timelineEvent = state.sendMode.timelineEvent + val showInThread = state.sendMode.timelineEvent.root.isThread() && state.rootThreadEventId == null + val rootThreadEventId = if (showInThread) timelineEvent.root.getRootThreadEventId() else null + state.rootThreadEventId?.let { + room.replyInThread( + rootThreadEventId = it, + replyInThreadText = action.text.toString(), + autoMarkdown = action.autoMarkdown, + eventReplied = timelineEvent) + } ?: room.replyToMessage( + eventReplied = timelineEvent, + replyText = action.text.toString(), + autoMarkdown = action.autoMarkdown, + showInThread = showInThread, + rootThreadEventId = rootThreadEventId + ) + + _viewEvents.post(MessageComposerViewEvents.MessageSent) + popDraft() } is SendMode.Voice -> { // do nothing @@ -677,7 +761,7 @@ class MessageComposerViewModel @AssistedInject constructor( _viewEvents.post(MessageComposerViewEvents.OpenRoomMemberProfile(whois.userId)) } - private fun sendPrefixedMessage(prefix: String, message: CharSequence) { + private fun sendPrefixedMessage(prefix: String, message: CharSequence, rootThreadEventId: String?) { val sequence = buildString { append(prefix) if (message.isNotEmpty()) { @@ -685,7 +769,9 @@ class MessageComposerViewModel @AssistedInject constructor( append(message) } } - room.sendTextMessage(sequence) + rootThreadEventId?.let { + room.replyInThread(it, sequence) + } ?: room.sendTextMessage(sequence) } /** @@ -722,14 +808,18 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun handleEndRecordingVoiceMessage(isCancelled: Boolean) { + private fun handleEndRecordingVoiceMessage(isCancelled: Boolean, rootThreadEventId: String? = null) { voiceMessageHelper.stopPlayback() if (isCancelled) { voiceMessageHelper.deleteRecording() } else { voiceMessageHelper.stopRecording(convertForSending = true)?.let { audioType -> if (audioType.duration > 1000) { - room.sendMedia(audioType.toContentAttachmentData(isVoiceMessage = true), false, emptySet()) + room.sendMedia( + attachment = audioType.toContentAttachmentData(isVoiceMessage = true), + compressBeforeSending = false, + roomIds = emptySet(), + rootThreadEventId = rootThreadEventId) } else { voiceMessageHelper.deleteRecording() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt index 915e1b3338..f90f3975c6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt @@ -17,7 +17,7 @@ package im.vector.app.features.home.room.detail.composer import com.airbnb.mvrx.MavericksState -import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent @@ -61,12 +61,13 @@ data class MessageComposerViewState( val roomId: String, val canSendMessage: CanSendStatus = CanSendStatus.Allowed, val isSendButtonVisible: Boolean = false, + val rootThreadEventId: String? = null, val sendMode: SendMode = SendMode.Regular("", false), val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle ) : MavericksState { val isVoiceRecording = when (voiceRecordingUiState) { - VoiceMessageRecorderView.RecordingUiState.Idle -> false + VoiceMessageRecorderView.RecordingUiState.Idle -> false is VoiceMessageRecorderView.RecordingUiState.Locked, VoiceMessageRecorderView.RecordingUiState.Draft, is VoiceMessageRecorderView.RecordingUiState.Recording -> true @@ -77,6 +78,9 @@ data class MessageComposerViewState( val isComposerVisible = canSendMessage.boolean() && !isVoiceRecording val isVoiceMessageRecorderVisible = canSendMessage.boolean() && !isSendButtonVisible - @Suppress("UNUSED") // needed by mavericks - constructor(args: RoomDetailArgs) : this(roomId = args.roomId) + constructor(args: TimelineArgs) : this( + roomId = args.roomId, + rootThreadEventId = args.threadTimelineArgs?.rootThreadEventId) + + fun isInThreadTimeline(): Boolean = rootThreadEventId != null } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt index 4a285da5f2..62c142238e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt @@ -37,13 +37,17 @@ import im.vector.app.core.extensions.trackItemsVisibilityChange import im.vector.app.core.platform.StateView import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.databinding.FragmentSearchBinding +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import kotlinx.parcelize.Parcelize import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId import javax.inject.Inject @Parcelize data class SearchArgs( - val roomId: String + val roomId: String, + val roomDisplayName: String?, + val roomAvatarUrl: String? ) : Parcelable class SearchFragment @Inject constructor( @@ -111,10 +115,25 @@ class SearchFragment @Inject constructor( searchViewModel.handle(SearchAction.Retry) } - override fun onItemClicked(event: Event) { - event.roomId?.let { - navigator.openRoom(requireContext(), it, event.eventId) - } + override fun onItemClicked(event: Event) = + navigateToEvent(event) + + /** + * Navigate and highlight the event. If this is a thread event, + * user will be redirected to the appropriate thread room + * @param event the event to navigate and highlight + */ + private fun navigateToEvent(event: Event) { + val roomId = event.roomId ?: return + event.getRootThreadEventId()?.let { + val threadTimelineArgs = ThreadTimelineArgs( + roomId = roomId, + displayName = fragmentArgs.roomDisplayName, + avatarUrl = fragmentArgs.roomAvatarUrl, + roomEncryptionTrustLevel = null, + rootThreadEventId = it) + navigator.openThread(requireContext(), threadTimelineArgs, event.eventId) + } ?: navigator.openRoom(requireContext(), roomId, event.eventId) } override fun loadMore() { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt index ccf83011a8..2cdc1a0d90 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt @@ -29,6 +29,7 @@ import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.epoxy.loadingItem import im.vector.app.core.epoxy.noResultItem import im.vector.app.core.resources.StringProvider +import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.core.ui.list.GenericHeaderItem_ import im.vector.app.features.home.AvatarRenderer import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence @@ -43,7 +44,8 @@ class SearchResultController @Inject constructor( private val session: Session, private val avatarRenderer: AvatarRenderer, private val stringProvider: StringProvider, - private val dateFormatter: VectorDateFormatter + private val dateFormatter: VectorDateFormatter, + private val userPreferencesProvider: UserPreferencesProvider ) : TypedEpoxyController() { var listener: Listener? = null @@ -122,6 +124,8 @@ class SearchResultController @Inject constructor( .spannable(spannable.toEpoxyCharSequence()) .sender(eventAndSender.sender ?: eventAndSender.event.senderId?.let { session.getRoomMember(it, data.roomId) }?.toMatrixItem()) + .threadDetails(event.threadDetails) + .areThreadMessagesEnabled(userPreferencesProvider.areThreadMessagesEnabled()) .listener { listener?.onItemClicked(eventAndSender.event) } .let { result.add(it) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt index 95dea2b8d2..2ec786fab2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt @@ -18,6 +18,8 @@ package im.vector.app.features.home.room.detail.search import android.widget.ImageView import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R @@ -29,6 +31,7 @@ import im.vector.app.core.extensions.setTextOrHide import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.AvatarRenderer import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence +import org.matrix.android.sdk.api.session.threads.ThreadDetails import org.matrix.android.sdk.api.util.MatrixItem @EpoxyModelClass(layout = R.layout.item_search_result) @@ -38,6 +41,9 @@ abstract class SearchResultItem : VectorEpoxyModel() { @EpoxyAttribute var formattedDate: String? = null @EpoxyAttribute lateinit var spannable: EpoxyCharSequence @EpoxyAttribute var sender: MatrixItem? = null + @EpoxyAttribute var threadDetails: ThreadDetails? = null + @EpoxyAttribute var areThreadMessagesEnabled: Boolean = false + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var listener: ClickListener? = null override fun bind(holder: Holder) { @@ -48,6 +54,36 @@ abstract class SearchResultItem : VectorEpoxyModel() { holder.memberNameView.setTextOrHide(sender?.getBestName()) holder.timeView.text = formattedDate holder.contentView.text = spannable.charSequence + + if (areThreadMessagesEnabled) { + threadDetails?.let { + if (it.isRootThread) { + showThreadSummary(holder) + holder.threadSummaryCounterTextView.text = it.numberOfThreads.toString() + holder.threadSummaryInfoTextView.text = it.threadSummaryLatestTextMessage.orEmpty() + + val userId = it.threadSummarySenderInfo?.userId ?: return@let + val displayName = it.threadSummarySenderInfo?.displayName + val avatarUrl = it.threadSummarySenderInfo?.avatarUrl + avatarRenderer.render(MatrixItem.UserItem(userId, displayName, avatarUrl), holder.threadSummaryAvatarImageView) + } else { + showFromThread(holder) + } + } ?: run { + holder.threadSummaryConstraintLayout.isVisible = false + holder.fromThreadConstraintLayout.isVisible = false + } + } + } + + private fun showThreadSummary(holder: Holder, show: Boolean = true) { + holder.threadSummaryConstraintLayout.isVisible = show + holder.fromThreadConstraintLayout.isVisible = !show + } + + private fun showFromThread(holder: Holder, show: Boolean = true) { + holder.threadSummaryConstraintLayout.isVisible = !show + holder.fromThreadConstraintLayout.isVisible = show } class Holder : VectorEpoxyHolder() { @@ -55,5 +91,10 @@ abstract class SearchResultItem : VectorEpoxyModel() { val memberNameView by bind(R.id.messageMemberNameView) val timeView by bind(R.id.messageTimeView) val contentView by bind(R.id.messageContentView) + val threadSummaryConstraintLayout by bind(R.id.searchThreadSummaryConstraintLayout) + val threadSummaryCounterTextView by bind(R.id.messageThreadSummaryCounterTextView) + val threadSummaryAvatarImageView by bind(R.id.messageThreadSummaryAvatarImageView) + val threadSummaryInfoTextView by bind(R.id.messageThreadSummaryInfoTextView) + val fromThreadConstraintLayout by bind(R.id.searchFromThreadConstraintLayout) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt index 4a9a03789f..1efc0377d3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt @@ -96,21 +96,26 @@ 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 ) + + fun isFromThreadTimeline(): Boolean = rootThreadEventId != null } interface Callback : BaseCallback, ReactionPillCallback, AvatarCallback, + ThreadCallback, UrlClickCallback, ReadReceiptsCallback, PreviewUrlCallback { @@ -141,7 +146,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } interface BaseCallback { - fun onEventCellClicked(informationData: MessageInformationData, messageContent: Any?, view: View) + fun onEventCellClicked(informationData: MessageInformationData, messageContent: Any?, view: View, isRootThreadEvent: Boolean) fun onEventLongClicked(informationData: MessageInformationData, messageContent: Any?, view: View): Boolean } @@ -150,6 +155,10 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec fun onMemberNameClicked(informationData: MessageInformationData) } + interface ThreadCallback { + fun onThreadSummaryClicked(eventId: String, isRootThreadEvent: Boolean): Boolean + } + interface ReadReceiptsCallback { fun onReadReceiptsClicked(readReceipts: List) fun onReadMarkerVisible() @@ -198,7 +207,12 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec // In some cases onChanged will be called before onRemoved and onInserted so position will be bigger than currentSnapshot.size. val prevList = currentSnapshot.subList(0, min(position, currentSnapshot.size)) val prevDisplayableEventIndex = prevList.indexOfLast { - timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId) + timelineEventVisibilityHelper.shouldShowEvent( + timelineEvent = it, + highlightedEventId = partialState.highlightedEventId, + isFromThreadTimeline = partialState.isFromThreadTimeline(), + rootThreadEventId = partialState.rootThreadEventId + ) } if (prevDisplayableEventIndex != -1 && currentSnapshot.getOrNull(prevDisplayableEventIndex)?.senderInfo?.userId == invalidatedSenderId) { modelCache[prevDisplayableEventIndex] = null @@ -313,6 +327,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } private fun submitSnapshot(newSnapshot: List) { + // Update is triggered on any DB change backgroundHandler.post { inSubmitList = true val diffCallback = TimelineEventDiffUtilCallback(currentSnapshot, newSnapshot) @@ -369,7 +384,11 @@ 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( + timelineEvent = it, + highlightedEventId = partialState.highlightedEventId, + isFromThreadTimeline = partialState.isFromThreadTimeline(), + rootThreadEventId = partialState.rootThreadEventId) } // Should be build if not cached or if model should be refreshed if (modelCache[position] == null || modelCache[position]?.isCacheable(partialState) == false) { @@ -436,7 +455,12 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec } val readReceipts = receiptsByEvents[event.eventId].orEmpty() return copy( - readReceiptsItem = readReceiptsItemFactory.create(event.eventId, readReceipts, callback), + readReceiptsItem = readReceiptsItemFactory.create( + event.eventId, + readReceipts, + callback, + partialState.isFromThreadTimeline() + ), formattedDayModel = formattedDayModel, mergedHeaderModel = mergedHeaderModel ) @@ -453,7 +477,12 @@ 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( + timelineEvent = event, + highlightedEventId = partialState.highlightedEventId, + isFromThreadTimeline = partialState.isFromThreadTimeline(), + rootThreadEventId = partialState.rootThreadEventId + )) { continue } // If the event is sent by us, we update the holder with the eventId and stop the search @@ -475,7 +504,11 @@ 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( + timelineEvent = event, + highlightedEventId = partialState.highlightedEventId, + isFromThreadTimeline = partialState.isFromThreadTimeline(), + rootThreadEventId = partialState.rootThreadEventId)) { lastShownEventId = event.eventId } if (lastShownEventId == null) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt index d7a57e6577..048a4754f5 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt @@ -48,6 +48,12 @@ sealed class EventSharedAction(@StringRes val titleRes: Int, data class Reply(val eventId: String) : EventSharedAction(R.string.reply, R.drawable.ic_reply) + data class ReplyInThread(val eventId: String) : + EventSharedAction(R.string.reply_in_thread, R.drawable.ic_reply_in_thread) + + object ViewInRoom : + EventSharedAction(R.string.view_in_room, R.drawable.ic_thread_view_in_room_menu_item) + data class Share(val eventId: String, val messageContent: MessageContent) : EventSharedAction(R.string.action_share, R.drawable.ic_share) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt index c1c145040e..0cf7e60eae 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionState.kt @@ -49,10 +49,15 @@ data class MessageActionState( // For actions val actions: List = 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() ?: "" diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt index 5e0db19d9e..24c5679438 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsBottomSheet.kt @@ -93,13 +93,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 ) ) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index ea54d91a78..745cb0c731 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -46,6 +46,7 @@ import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysBackupState import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isTextMessage +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.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageFormat @@ -59,6 +60,8 @@ import org.matrix.android.sdk.api.session.room.send.SendState 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.hasBeenEdited +import org.matrix.android.sdk.api.session.room.timeline.isPoll +import org.matrix.android.sdk.api.session.room.timeline.isSticker import org.matrix.android.sdk.flow.flow import org.matrix.android.sdk.flow.unwrap @@ -324,6 +327,14 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted add(EventSharedAction.Reply(eventId)) } + if (canReplyInThread(timelineEvent, messageContent, actionPermissions)) { + add(EventSharedAction.ReplyInThread(eventId)) + } + + if (canViewInRoom(timelineEvent, messageContent, actionPermissions)) { + add(EventSharedAction.ViewInRoom) + } + if (canEndPoll(timelineEvent, actionPermissions)) { add(EventSharedAction.EndPoll(timelineEvent.eventId)) } @@ -430,6 +441,59 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted } } + /** + * Determine whether or not the Reply In Thread bottom sheet action will be visible + * to the user + */ + private fun canReplyInThread(event: TimelineEvent, + messageContent: MessageContent?, + actionPermissions: ActionPermissions): Boolean { + if (!vectorPreferences.areThreadMessagesEnabled()) return false + if (initialState.isFromThreadTimeline) return false + if (event.root.isThread()) return false + if (event.root.getClearType() != EventType.MESSAGE && + !event.isSticker() && !event.isPoll()) 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, + MessageType.MSGTYPE_POLL_START, + MessageType.MSGTYPE_STICKER_LOCAL -> true + else -> false + } + } + + /** + * Determine whether or not the view in room action will be available for the current event + */ + private fun canViewInRoom(event: TimelineEvent, + messageContent: MessageContent?, + actionPermissions: ActionPermissions): Boolean { + if (!vectorPreferences.areThreadMessagesEnabled()) return false + if (!initialState.isFromThreadTimeline) return false + if (event.root.getClearType() != EventType.MESSAGE && + !event.isSticker() && !event.isPoll()) 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, + MessageType.MSGTYPE_POLL_START, + MessageType.MSGTYPE_STICKER_LOCAL -> event.root.threadDetails?.isRootThread ?: false + else -> false + } + } + private fun canQuote(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean { // Only event of type EventType.MESSAGE are supported for the moment if (event.root.getClearType() != EventType.MESSAGE) return false diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/TimelineEventFragmentArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/TimelineEventFragmentArgs.kt index 1bb1a876bd..2bd3c54d52 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/TimelineEventFragmentArgs.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/TimelineEventFragmentArgs.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt index 97f2618fe6..0161f0b55d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt @@ -101,7 +101,11 @@ class CallItemFactory @Inject constructor( createCallTileTimelineItem( roomSummary = roomSummary, callId = callEventGrouper.callId, - callStatus = if (callEventGrouper.callWasMissed()) CallTileTimelineItem.CallStatus.MISSED else CallTileTimelineItem.CallStatus.ENDED, + callStatus = if (callEventGrouper.callWasAnswered()) { + CallTileTimelineItem.CallStatus.ENDED + } else { + CallTileTimelineItem.CallStatus.MISSED + }, callKind = callKind, callback = params.callback, highlight = params.isHighlighted, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt index 4f8a36e234..413ceb6380 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/EncryptedItemFactory.kt @@ -106,7 +106,12 @@ class EncryptedItemFactory @Inject constructor(private val messageInformationDat } val informationData = messageInformationDataFactory.create(params) - val attributes = attributesFactory.create(event.root.content.toModel(), informationData, params.callback) + val threadDetails = if (params.isFromThreadTimeline()) null else event.root.threadDetails + val attributes = attributesFactory.create( + messageContent = event.root.content.toModel(), + informationData = informationData, + callback = params.callback, + threadDetails = threadDetails) return MessageTextItem_() .leftGuideline(avatarSizeProvider.leftGuideline) .highlighted(params.isHighlighted) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt index e378969b4a..99a026a0cf 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MergedHeaderItemFactory.kt @@ -83,7 +83,13 @@ 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, + partialState.isFromThreadTimeline()) return if (mergedEvents.isEmpty()) { null } else { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index c501f5ea24..64dbef198a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -84,6 +84,7 @@ import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.RelationType +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.message.MessageAudioContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent @@ -106,6 +107,7 @@ import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import javax.inject.Inject class MessageItemFactory @Inject constructor( @@ -125,6 +127,7 @@ class MessageItemFactory @Inject constructor( private val noticeItemFactory: NoticeItemFactory, private val avatarSizeProvider: AvatarSizeProvider, private val pillsPostProcessorFactory: PillsPostProcessor.Factory, + private val lightweightSettingsStorage: LightweightSettingsStorage, private val spanUtils: SpanUtils, private val session: Session, private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker, @@ -148,9 +151,11 @@ class MessageItemFactory @Inject constructor( event.root.eventId ?: return null roomId = event.roomId val informationData = messageInformationDataFactory.create(params) + val threadDetails = if (params.isFromThreadTimeline()) null else event.root.threadDetails + if (event.root.isRedacted()) { // message is redacted - val attributes = messageItemAttributesFactory.create(null, informationData, callback) + val attributes = messageItemAttributesFactory.create(null, informationData, callback, threadDetails) return buildRedactedItem(attributes, highlight) } @@ -165,7 +170,14 @@ 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) + + if (lightweightSettingsStorage.areThreadMessagesEnabled() && !params.isFromThreadTimeline() && event.root.isThread()) { + // This is a thread event and we will [debug] display it when we are in the main timeline + return noticeItemFactory.create(params) + } + + // always hide summary when we are on thread timeline + val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback, threadDetails) // val all = event.root.toContent() // val ev = all.toModel() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt index 8a74a6d207..d477a3d40e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt @@ -26,7 +26,11 @@ import javax.inject.Inject class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: AvatarRenderer) { - fun create(eventId: String, readReceipts: List, callback: TimelineEventController.Callback?): ReadReceiptsItem? { + fun create( + eventId: String, + readReceipts: List, + callback: TimelineEventController.Callback?, + isFromThreadTimeLine: Boolean): ReadReceiptsItem? { if (readReceipts.isEmpty()) { return null } @@ -41,6 +45,7 @@ class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: Av .eventId(eventId) .readReceipts(readReceiptsData) .avatarRenderer(avatarRenderer) + .shouldHideReadReceipts(isFromThreadTimeLine) .clickListener { callback?.onReadReceiptsClicked(readReceiptsData) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineFactory.kt index b57e39b3cf..3ec1366131 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineFactory.kt @@ -35,8 +35,14 @@ private val secondaryTimelineAllowedTypes = listOf( class TimelineFactory @Inject constructor(private val session: Session, private val timelineSettingsFactory: TimelineSettingsFactory) { - fun createTimeline(coroutineScope: CoroutineScope, mainRoom: Room, eventId: String?): Timeline { - val settings = timelineSettingsFactory.create() + fun createTimeline( + coroutineScope: CoroutineScope, + mainRoom: Room, + eventId: String?, + rootThreadEventId: String? + ): Timeline { + val settings = timelineSettingsFactory.create(rootThreadEventId) + if (!session.vectorCallService.protocolChecker.supportVirtualRooms) { return mainRoom.createTimeline(eventId, settings) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index dfe1cc1d9b..b41e1d8f25 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -44,8 +44,17 @@ 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( + timelineEvent = event, + highlightedEventId = params.highlightedEventId, + isFromThreadTimeline = params.isFromThreadTimeline(), + rootThreadEventId = params.rootThreadEventId)) { + return buildEmptyItem( + event, + params.prevEvent, + params.highlightedEventId, + params.rootThreadEventId, + params.isFromThreadTimeline()) } // Manage state event differently, to check validity @@ -134,11 +143,24 @@ 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, + params.isFromThreadTimeline()) } - 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?, + isFromThreadTimeline: Boolean): TimelineEmptyItem { + val isNotBlank = prevEvent == null || timelineEventVisibilityHelper.shouldShowEvent( + timelineEvent = prevEvent, + highlightedEventId = highlightedEventId, + isFromThreadTimeline = isFromThreadTimeline, + rootThreadEventId = rootThreadEventId) return TimelineEmptyItem_() .id(timelineEvent.localId) .eventId(timelineEvent.eventId) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt index cdfedb2925..8479d6b589 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt @@ -34,5 +34,10 @@ data class TimelineItemFactoryParams( val highlightedEventId: String? get() = partialState.highlightedEventId + val rootThreadEventId: String? + get() = partialState.rootThreadEventId + val isHighlighted = highlightedEventId == event.eventId + + fun isFromThreadTimeline(): Boolean = rootThreadEventId != null } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index ae541217bf..c7be395693 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.extensions.appendNl import org.matrix.android.sdk.api.extensions.orFalse 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.room.model.GuestAccess import org.matrix.android.sdk.api.session.room.model.Membership @@ -104,6 +105,7 @@ class NoticeEventFormatter @Inject constructor( EventType.STATE_SPACE_CHILD, EventType.STATE_SPACE_PARENT, EventType.REDACTION, + EventType.STICKER, EventType.POLL_RESPONSE, EventType.POLL_END -> formatDebug(timelineEvent.root) else -> { @@ -194,7 +196,8 @@ class NoticeEventFormatter @Inject constructor( } private fun formatDebug(event: Event): CharSequence { - return "Debug: event type \"${event.getClearType()}\"" + val threadPrefix = if (event.isThread()) "thread" else "" + return "Debug: $threadPrefix event type \"${event.getClearType()}\"" } private fun formatRoomCreateEvent(event: Event, isDm: Boolean): CharSequence? { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt index 679613d262..845b765101 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageItemAttributesFactory.kt @@ -16,22 +16,29 @@ package im.vector.app.features.home.room.detail.timeline.helper import im.vector.app.EmojiCompatFontProvider +import im.vector.app.R +import im.vector.app.core.resources.StringProvider +import im.vector.app.core.resources.UserPreferencesProvider import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.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( private val avatarRenderer: AvatarRenderer, private val messageColorProvider: MessageColorProvider, private val avatarSizeProvider: AvatarSizeProvider, + private val stringProvider: StringProvider, + private val preferencesProvider: UserPreferencesProvider, private val emojiCompatFontProvider: EmojiCompatFontProvider) { fun create(messageContent: Any?, informationData: MessageInformationData, - callback: TimelineEventController.Callback?): AbsMessageItem.Attributes { + callback: TimelineEventController.Callback?, + threadDetails: ThreadDetails? = null): AbsMessageItem.Attributes { return AbsMessageItem.Attributes( avatarSize = avatarSizeProvider.avatarSize, informationData = informationData, @@ -41,15 +48,19 @@ class MessageItemAttributesFactory @Inject constructor( callback?.onEventLongClicked(informationData, messageContent, view) ?: false }, itemClickListener = { view -> - callback?.onEventCellClicked(informationData, messageContent, view) + callback?.onEventCellClicked(informationData, messageContent, view, threadDetails?.isRootThread ?: false) }, memberClickListener = { callback?.onMemberNameClicked(informationData) }, reactionPillCallback = callback, avatarCallback = callback, + threadCallback = callback, readReceiptsCallback = callback, - emojiTypeFace = emojiCompatFontProvider.typeface + emojiTypeFace = emojiCompatFontProvider.typeface, + decryptionErrorMessage = stringProvider.getString(R.string.encrypted_message), + threadDetails = threadDetails, + areThreadMessagesEnabled = preferencesProvider.areThreadMessagesEnabled() ) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineControllerInterceptorHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineControllerInterceptorHelper.kt index 7165921b35..8a0e1e18fd 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineControllerInterceptorHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineControllerInterceptorHelper.kt @@ -115,7 +115,10 @@ class TimelineControllerInterceptorHelper(private val positionOfReadMarker: KMut private fun MutableList>.addForwardPrefetchIfNeeded(timeline: Timeline?, callback: TimelineEventController.Callback?) { val shouldAddForwardPrefetch = timeline?.hasMoreToLoad(Timeline.Direction.FORWARDS) ?: false if (shouldAddForwardPrefetch) { - val indexOfPrefetchForward = DEFAULT_PREFETCH_THRESHOLD.coerceAtMost(size - 1) + val indexOfPrefetchForward = DEFAULT_PREFETCH_THRESHOLD + .coerceAtMost(size - 1) + .coerceAtLeast(0) + val loadingItem = LoadingItem_() .id("prefetch_forward_loading${System.currentTimeMillis()}") .showLoader(false) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt index 580d7d18cf..f317eb4f9a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt @@ -21,6 +21,8 @@ import im.vector.app.core.resources.UserPreferencesProvider 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 +39,13 @@ 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, index: Int, minSize: Int, eventIdToHighlight: String?): List { + private fun nextSameTypeEvents( + timelineEvents: List, + index: Int, + minSize: Int, + eventIdToHighlight: String?, + rootThreadEventId: String?, + isFromThreadTimeline: Boolean): List { if (index >= timelineEvents.size - 1) { return emptyList() } @@ -59,11 +67,18 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen } else { nextSameDayEvents.subList(0, indexOfFirstDifferentEventType) } - val filteredSameTypeEvents = sameTypeEvents.filter { shouldShowEvent(it, eventIdToHighlight) } + val filteredSameTypeEvents = sameTypeEvents.filter { + shouldShowEvent( + timelineEvent = it, + highlightedEventId = eventIdToHighlight, + isFromThreadTimeline = isFromThreadTimeline, + rootThreadEventId = rootThreadEventId + ) + } if (filteredSameTypeEvents.size < minSize) { return emptyList() } - return filteredSameTypeEvents + return filteredSameTypeEvents } /** @@ -74,23 +89,35 @@ 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, index: Int, minSize: Int, eventIdToHighlight: String?): List { + fun prevSameTypeEvents( + timelineEvents: List, + index: Int, + minSize: Int, + eventIdToHighlight: String?, + rootThreadEventId: String?, + isFromThreadTimeline: Boolean): List { val prevSub = timelineEvents.subList(0, index + 1) return prevSub .reversed() .let { - nextSameTypeEvents(it, 0, minSize, eventIdToHighlight) + nextSameTypeEvents(it, 0, minSize, eventIdToHighlight, rootThreadEventId, isFromThreadTimeline) } } /** * @param timelineEvent the event to check for visibility * @param highlightedEventId can be checked to force visibility to true + * @param rootThreadEventId if this param is null it means we are in the original timeline * @return true if the event should be shown in the timeline. */ - fun shouldShowEvent(timelineEvent: TimelineEvent, highlightedEventId: String?): Boolean { + fun shouldShowEvent( + timelineEvent: TimelineEvent, + highlightedEventId: String?, + isFromThreadTimeline: Boolean, + rootThreadEventId: String? + ): Boolean { // If show hidden events is true we should always display something - if (userPreferencesProvider.shouldShowHiddenEvents()) { + if (userPreferencesProvider.shouldShowHiddenEvents() && !isFromThreadTimeline) { return true } // We always show highlighted event @@ -100,18 +127,35 @@ 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, isFromThreadTimeline) } private fun TimelineEvent.isDisplayable(): Boolean { return TimelineDisplayableEvents.DISPLAYABLE_TYPES.contains(root.getClearType()) } - private fun TimelineEvent.shouldBeHidden(): Boolean { - if (root.isRedacted() && !userPreferencesProvider.shouldShowRedactedMessages()) { + private fun TimelineEvent.shouldBeHidden(rootThreadEventId: String?, isFromThreadTimeline: Boolean): Boolean { + if (root.isRedacted() && !userPreferencesProvider.shouldShowRedactedMessages() && root.threadDetails?.isRootThread == false) { return true } + + // We should not display deleted thread messages within the normal timeline + if (root.isRedacted() && + userPreferencesProvider.areThreadMessagesEnabled() && + !isFromThreadTimeline && + (root.isThread() || root.threadDetails?.isThread == true)) { + return true + } + if (root.isRedacted() && + !userPreferencesProvider.shouldShowRedactedMessages() && + userPreferencesProvider.areThreadMessagesEnabled() && + isFromThreadTimeline && + root.isThread()) { + return true + } + if (root.getRelationContent()?.type == RelationType.REPLACE) { return true } @@ -120,6 +164,18 @@ 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 (userPreferencesProvider.areThreadMessagesEnabled() && !isFromThreadTimeline && root.isThread()) { + return true + } + + // Allow only the the threads within the rootThreadEventId along with the root event + if (userPreferencesProvider.areThreadMessagesEnabled() && isFromThreadTimeline) { + return if (root.getRootThreadEventId() == rootThreadEventId) { + false + } else root.eventId != rootThreadEventId + } + return false } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt index 3910204293..4ff8a9fa43 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt @@ -108,11 +108,8 @@ class CallSignalingEventsGroup(private val group: TimelineEventsGroup) { } } - /** - * Returns true if there are only events from one side. - */ - fun callWasMissed(): Boolean { - return group.events.distinctBy { it.senderInfo.userId }.size == 1 + fun callWasAnswered(): Boolean { + return getAnswer() != null } private fun getAnswer(): TimelineEvent? { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineSettingsFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineSettingsFactory.kt index 3aee65bf19..8b7dcc9c72 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineSettingsFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineSettingsFactory.kt @@ -22,9 +22,11 @@ import javax.inject.Inject class TimelineSettingsFactory @Inject constructor(private val userPreferencesProvider: UserPreferencesProvider) { - fun create(): TimelineSettings { + fun create(rootThreadEventId: String?): TimelineSettings { return TimelineSettings( initialSize = 30, - buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts()) + buildReadReceipts = userPreferencesProvider.shouldShowReadReceipts(), + rootThreadEventId = rootThreadEventId + ) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt index b53495fdaf..4bd84ae603 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -19,11 +19,15 @@ package im.vector.app.features.home.room.detail.timeline.item import android.graphics.Typeface import android.view.View import android.widget.ImageView +import android.widget.LinearLayout import android.widget.ProgressBar +import android.widget.RelativeLayout import android.widget.TextView import androidx.annotation.IdRes +import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import com.airbnb.epoxy.EpoxyAttribute import im.vector.app.R import im.vector.app.core.epoxy.ClickListener @@ -32,6 +36,8 @@ 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 /** * Base timeline item that adds an optional information bar with the sender avatar, name, time, send state @@ -61,6 +67,12 @@ abstract class AbsMessageItem : AbsBaseMessageItem } } + private val _threadClickListener = object : ClickListener { + override fun invoke(p1: View) { + attributes.threadCallback?.onThreadSummaryClicked(attributes.informationData.eventId, attributes.threadDetails?.isRootThread ?: false) + } + } + override fun bind(holder: H) { super.bind(holder) if (attributes.informationData.showInformation) { @@ -98,6 +110,35 @@ abstract class AbsMessageItem : AbsBaseMessageItem // Render send state indicator holder.sendStateImageView.render(attributes.informationData.sendStateDecoration) holder.eventSendingIndicator.isVisible = attributes.informationData.sendStateDecoration == SendStateDecoration.SENDING_MEDIA + + // Threads + if (attributes.areThreadMessagesEnabled) { + holder.threadSummaryConstraintLayout.onClick(_threadClickListener) + attributes.threadDetails?.let { threadDetails -> + holder.threadSummaryConstraintLayout.isVisible = threadDetails.isRootThread + holder.threadSummaryCounterTextView.text = threadDetails.numberOfThreads.toString() + holder.threadSummaryInfoTextView.text = threadDetails.threadSummaryLatestTextMessage ?: attributes.decryptionErrorMessage + + val userId = threadDetails.threadSummarySenderInfo?.userId ?: return@let + val displayName = threadDetails.threadSummarySenderInfo?.displayName + val avatarUrl = threadDetails.threadSummarySenderInfo?.avatarUrl + attributes.avatarRenderer.render(MatrixItem.UserItem(userId, displayName, avatarUrl), holder.threadSummaryAvatarImageView) + updateHighlightedMessageHeight(holder, true) + } ?: run { + holder.threadSummaryConstraintLayout.isVisible = false + updateHighlightedMessageHeight(holder, false) + } + } + } + + private fun updateHighlightedMessageHeight(holder: Holder, isExpanded: Boolean) { + holder.checkableBackground.updateLayoutParams { + if (isExpanded) { + addRule(RelativeLayout.ALIGN_BOTTOM, holder.threadSummaryConstraintLayout.id) + } else { + addRule(RelativeLayout.ALIGN_BOTTOM, holder.informationBottom.id) + } + } } override fun unbind(holder: H) { @@ -106,17 +147,25 @@ abstract class AbsMessageItem : AbsBaseMessageItem holder.avatarImageView.setOnLongClickListener(null) holder.memberNameView.setOnClickListener(null) holder.memberNameView.setOnLongClickListener(null) + attributes.avatarRenderer.clear(holder.threadSummaryAvatarImageView) + holder.threadSummaryConstraintLayout.setOnClickListener(null) super.unbind(holder) } private fun Attributes.getMemberNameColor() = messageColorProvider.getMemberNameTextColor(informationData.matrixItem) abstract class Holder(@IdRes stubId: Int) : AbsBaseMessageItem.Holder(stubId) { + val avatarImageView by bind(R.id.messageAvatarImageView) val memberNameView by bind(R.id.messageMemberNameView) val timeView by bind(R.id.messageTimeView) val sendStateImageView by bind(R.id.messageSendStateImageView) val eventSendingIndicator by bind(R.id.eventSendingIndicator) + val informationBottom by bind(R.id.informationBottom) + val threadSummaryConstraintLayout by bind(R.id.messageThreadSummaryConstraintLayout) + val threadSummaryCounterTextView by bind(R.id.messageThreadSummaryCounterTextView) + val threadSummaryAvatarImageView by bind(R.id.messageThreadSummaryAvatarImageView) + val threadSummaryInfoTextView by bind(R.id.messageThreadSummaryInfoTextView) } /** @@ -132,8 +181,12 @@ abstract class AbsMessageItem : AbsBaseMessageItem val memberClickListener: ClickListener? = null, override val reactionPillCallback: TimelineEventController.ReactionPillCallback? = null, val avatarCallback: TimelineEventController.AvatarCallback? = null, + val threadCallback: TimelineEventController.ThreadCallback? = null, override val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, - val emojiTypeFace: Typeface? = null + val emojiTypeFace: Typeface? = null, + val decryptionErrorMessage: String? = null, + val threadDetails: ThreadDetails? = null, + val areThreadMessagesEnabled: Boolean = false ) : AbsBaseMessageItem.Attributes { // Have to override as it's used to diff epoxy items @@ -145,6 +198,7 @@ abstract class AbsMessageItem : AbsBaseMessageItem if (avatarSize != other.avatarSize) return false if (informationData != other.informationData) return false + if (threadDetails != other.threadDetails) return false return true } @@ -152,6 +206,8 @@ abstract class AbsMessageItem : AbsBaseMessageItem override fun hashCode(): Int { var result = avatarSize result = 31 * result + informationData.hashCode() + result = 31 * result + threadDetails.hashCode() + return result } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt index 689d7e6768..357139648d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt @@ -78,7 +78,8 @@ abstract class NoticeItem : BaseEventItem() { val noticeText: EpoxyCharSequence, val itemLongClickListener: View.OnLongClickListener? = null, val readReceiptsCallback: TimelineEventController.ReadReceiptsCallback? = null, - val avatarClickListener: ClickListener? = null + val avatarClickListener: ClickListener? = null, + val threadSummaryClickListener: ClickListener? = null ) companion object { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/ReadReceiptsItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/ReadReceiptsItem.kt index 650c804cfa..4f29253264 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/ReadReceiptsItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/ReadReceiptsItem.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.timeline.item +import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelWithHolder @@ -31,6 +32,7 @@ abstract class ReadReceiptsItem : EpoxyModelWithHolder( @EpoxyAttribute lateinit var eventId: String @EpoxyAttribute lateinit var readReceipts: List + @EpoxyAttribute var shouldHideReadReceipts: Boolean = false @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) lateinit var avatarRenderer: AvatarRenderer @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) lateinit var clickListener: ClickListener @@ -42,6 +44,7 @@ abstract class ReadReceiptsItem : EpoxyModelWithHolder( super.bind(holder) holder.readReceiptsView.onClick(clickListener) holder.readReceiptsView.render(readReceipts, avatarRenderer) + holder.readReceiptsView.isVisible = !shouldHideReadReceipts } override fun unbind(holder: Holder) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/merged/MergedTimelines.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/merged/MergedTimelines.kt index 535b733dc2..55e0cce9f7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/merged/MergedTimelines.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/merged/MergedTimelines.kt @@ -110,7 +110,7 @@ class MergedTimelines( secondaryTimeline.removeAllListeners() } - override fun start() { + override fun start(rootThreadEventId: String?) { mainTimeline.start() secondaryTimeline.start() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/views/RoomDetailLazyLoadedViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/views/RoomDetailLazyLoadedViews.kt index fafb49ad5c..a7eb6ee78f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/views/RoomDetailLazyLoadedViews.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/views/RoomDetailLazyLoadedViews.kt @@ -19,7 +19,7 @@ package im.vector.app.features.home.room.detail.views import android.view.View import android.view.ViewStub import im.vector.app.core.ui.views.FailedMessagesWarningView -import im.vector.app.databinding.FragmentRoomDetailBinding +import im.vector.app.databinding.FragmentTimelineBinding import im.vector.app.features.invite.VectorInviteView import kotlin.reflect.KMutableProperty0 @@ -29,12 +29,12 @@ import kotlin.reflect.KMutableProperty0 */ class RoomDetailLazyLoadedViews { - private var roomDetailBinding: FragmentRoomDetailBinding? = null + private var roomDetailBinding: FragmentTimelineBinding? = null private var failedMessagesWarningView: FailedMessagesWarningView? = null private var inviteView: VectorInviteView? = null - fun bind(roomDetailBinding: FragmentRoomDetailBinding) { + fun bind(roomDetailBinding: FragmentTimelineBinding) { this.roomDetailBinding = roomDetailBinding } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/widget/RoomWidgetsBottomSheet.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/widget/RoomWidgetsBottomSheet.kt index aa6966254f..65f3d16ad4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/widget/RoomWidgetsBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/widget/RoomWidgetsBottomSheet.kt @@ -30,8 +30,8 @@ import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment import im.vector.app.core.resources.ColorProvider import im.vector.app.databinding.BottomSheetGenericListWithTitleBinding import im.vector.app.features.home.room.detail.RoomDetailAction -import im.vector.app.features.home.room.detail.RoomDetailViewModel import im.vector.app.features.home.room.detail.RoomDetailViewState +import im.vector.app.features.home.room.detail.TimelineViewModel import im.vector.app.features.navigation.Navigator import org.matrix.android.sdk.api.session.widgets.model.Widget import javax.inject.Inject @@ -48,7 +48,7 @@ class RoomWidgetsBottomSheet : @Inject lateinit var colorProvider: ColorProvider @Inject lateinit var navigator: Navigator - private val roomDetailViewModel: RoomDetailViewModel by parentFragmentViewModel() + private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetGenericListWithTitleBinding { return BottomSheetGenericListWithTitleBinding.inflate(inflater, container, false) @@ -61,7 +61,7 @@ class RoomWidgetsBottomSheet : views.bottomSheetTitle.textSize = 20f views.bottomSheetTitle.setTextColor(colorProvider.getColorFromAttribute(R.attr.vctr_content_primary)) epoxyController.listener = this - roomDetailViewModel.onAsync(RoomDetailViewState::activeRoomWidgets) { + timelineViewModel.onAsync(RoomDetailViewState::activeRoomWidgets) { epoxyController.setData(it) } } @@ -72,13 +72,13 @@ class RoomWidgetsBottomSheet : super.onDestroyView() } - override fun didSelectWidget(widget: Widget) = withState(roomDetailViewModel) { + override fun didSelectWidget(widget: Widget) = withState(timelineViewModel) { navigator.openRoomWidget(requireContext(), it.roomId, widget) dismiss() } override fun didSelectManageWidgets() { - roomDetailViewModel.handle(RoomDetailAction.OpenIntegrationManager) + timelineViewModel.handle(RoomDetailAction.OpenIntegrationManager) dismiss() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt new file mode 100644 index 0000000000..ca18060c51 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt @@ -0,0 +1,163 @@ +/* + * 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 im.vector.app.features.home.room.threads + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.FragmentTransaction +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.core.extensions.addFragmentToBackstack +import im.vector.app.core.extensions.replaceFragment +import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.databinding.ActivityThreadsBinding +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.TimelineFragment +import im.vector.app.features.home.room.detail.arguments.TimelineArgs +import im.vector.app.features.home.room.threads.arguments.ThreadListArgs +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs +import im.vector.app.features.home.room.threads.list.views.ThreadListFragment +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import javax.inject.Inject + +@AndroidEntryPoint +class ThreadsActivity : VectorBaseActivity() { + + @Inject + lateinit var avatarRenderer: AvatarRenderer + +// private val roomThreadDetailFragment: RoomThreadDetailFragment? +// get() { +// return supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as? RoomThreadDetailFragment +// } + + override fun getBinding() = ActivityThreadsBinding.inflate(layoutInflater) + + override fun getCoordinatorLayout() = views.coordinatorLayout + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + initFragment() + } + + private fun initFragment() { + if (isFirstCreation()) { + when (val fragment = fragmentToNavigate()) { + is DisplayFragment.ThreadList -> { + initThreadListFragment(fragment.threadListArgs) + } + is DisplayFragment.ThreadTimeLine -> { + initThreadTimelineFragment(fragment.threadTimelineArgs) + } + is DisplayFragment.ErrorFragment -> { + finish() + } + } + } + } + + private fun initThreadListFragment(threadListArgs: ThreadListArgs) { + replaceFragment( + views.threadsActivityFragmentContainer, + ThreadListFragment::class.java, + threadListArgs) + } + + private fun initThreadTimelineFragment(threadTimelineArgs: ThreadTimelineArgs) = + replaceFragment( + views.threadsActivityFragmentContainer, + TimelineFragment::class.java, + TimelineArgs( + roomId = threadTimelineArgs.roomId, + eventId = getEventIdToNavigate(), + threadTimelineArgs = threadTimelineArgs + )) + + /** + * This function is used to navigate to the selected thread timeline. + * One usage of that is from the Threads Activity + */ + fun navigateToThreadTimeline( + timelineEvent: TimelineEvent) { + val roomThreadDetailArgs = ThreadTimelineArgs( + roomId = timelineEvent.roomId, + displayName = timelineEvent.senderInfo.displayName, + avatarUrl = timelineEvent.senderInfo.avatarUrl, + roomEncryptionTrustLevel = null, + rootThreadEventId = timelineEvent.eventId) + val commonOption: (FragmentTransaction) -> Unit = { + it.setCustomAnimations( + R.anim.animation_slide_in_right, + R.anim.animation_slide_out_left, + R.anim.animation_slide_in_left, + R.anim.animation_slide_out_right) + } + addFragmentToBackstack( + container = views.threadsActivityFragmentContainer, + fragmentClass = TimelineFragment::class.java, + params = TimelineArgs( + roomId = timelineEvent.roomId, + threadTimelineArgs = roomThreadDetailArgs + ), + option = commonOption + ) + } + + /** + * Determine in witch fragment we should navigate + */ + private fun fragmentToNavigate(): DisplayFragment { + getThreadTimelineArgs()?.let { + return DisplayFragment.ThreadTimeLine(it) + } + getThreadListArgs()?.let { + return DisplayFragment.ThreadList(it) + } + return DisplayFragment.ErrorFragment + } + + private fun getThreadTimelineArgs(): ThreadTimelineArgs? = intent?.extras?.getParcelable(THREAD_TIMELINE_ARGS) + private fun getThreadListArgs(): ThreadListArgs? = intent?.extras?.getParcelable(THREAD_LIST_ARGS) + private fun getEventIdToNavigate(): String? = intent?.extras?.getString(THREAD_EVENT_ID_TO_NAVIGATE) + + companion object { + // private val FRAGMENT_TAG = RoomThreadDetailFragment::class.simpleName + const val THREAD_TIMELINE_ARGS = "THREAD_TIMELINE_ARGS" + const val THREAD_EVENT_ID_TO_NAVIGATE = "THREAD_EVENT_ID_TO_NAVIGATE" + const val THREAD_LIST_ARGS = "THREAD_LIST_ARGS" + + fun newIntent( + context: Context, + threadTimelineArgs: ThreadTimelineArgs?, + threadListArgs: ThreadListArgs?, + eventIdToNavigate: String? = null + ): Intent { + return Intent(context, ThreadsActivity::class.java).apply { + putExtra(THREAD_TIMELINE_ARGS, threadTimelineArgs) + putExtra(THREAD_EVENT_ID_TO_NAVIGATE, eventIdToNavigate) + putExtra(THREAD_LIST_ARGS, threadListArgs) + } + } + } + + sealed class DisplayFragment { + data class ThreadList(val threadListArgs: ThreadListArgs) : DisplayFragment() + data class ThreadTimeLine(val threadTimelineArgs: ThreadTimelineArgs) : DisplayFragment() + object ErrorFragment : DisplayFragment() + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadListArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadListArgs.kt new file mode 100644 index 0000000000..aa3746ea41 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadListArgs.kt @@ -0,0 +1,29 @@ +/* + * 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 im.vector.app.features.home.room.threads.arguments + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel + +@Parcelize +data class ThreadListArgs( + val roomId: String, + val displayName: String?, + val avatarUrl: String?, + val roomEncryptionTrustLevel: RoomEncryptionTrustLevel? +) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadTimelineArgs.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadTimelineArgs.kt new file mode 100644 index 0000000000..aadad3d97c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/arguments/ThreadTimelineArgs.kt @@ -0,0 +1,30 @@ +/* + * 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 im.vector.app.features.home.room.threads.arguments + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.matrix.android.sdk.api.crypto.RoomEncryptionTrustLevel + +@Parcelize +data class ThreadTimelineArgs( + val roomId: String, + val displayName: String?, + val avatarUrl: String?, + val roomEncryptionTrustLevel: RoomEncryptionTrustLevel?, + val rootThreadEventId: String? = null +) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/model/ThreadListItem.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/model/ThreadListItem.kt new file mode 100644 index 0000000000..2364e86166 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/model/ThreadListItem.kt @@ -0,0 +1,106 @@ +/* + * Copyright 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 im.vector.app.features.home.room.threads.list.model + +import android.widget.ImageView +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.epoxy.ClickListener +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.epoxy.onClick +import im.vector.app.core.extensions.clearDrawables +import im.vector.app.core.extensions.setLeftDrawable +import im.vector.app.core.utils.DimensionConverter +import im.vector.app.features.displayname.getBestName +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass(layout = R.layout.item_thread) +abstract class ThreadListItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer + @EpoxyAttribute lateinit var matrixItem: MatrixItem + @EpoxyAttribute lateinit var title: String + @EpoxyAttribute lateinit var date: String + @EpoxyAttribute lateinit var rootMessage: String + @EpoxyAttribute lateinit var lastMessage: String + @EpoxyAttribute var threadNotificationState: ThreadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE + @EpoxyAttribute lateinit var lastMessageCounter: String + @EpoxyAttribute var rootMessageDeleted: Boolean = false + @EpoxyAttribute var lastMessageMatrixItem: MatrixItem? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var itemClickListener: ClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + holder.rootView.onClick(itemClickListener) + avatarRenderer.render(matrixItem, holder.avatarImageView) + holder.avatarImageView.contentDescription = matrixItem.getBestName() + holder.titleTextView.text = title + holder.dateTextView.text = date + if (rootMessageDeleted) { + holder.rootMessageTextView.text = holder.view.context.getString(R.string.event_redacted) + holder.rootMessageTextView.setLeftDrawable(R.drawable.ic_trash_16, R.attr.vctr_content_tertiary) + holder.rootMessageTextView.compoundDrawablePadding = DimensionConverter(holder.view.context.resources).dpToPx(10) + } else { + holder.rootMessageTextView.text = rootMessage + holder.rootMessageTextView.clearDrawables() + } + // Last message summary + lastMessageMatrixItem?.let { + avatarRenderer.render(it, holder.lastMessageAvatarImageView) + } + holder.lastMessageAvatarImageView.contentDescription = lastMessageMatrixItem?.getBestName() + holder.lastMessageTextView.text = lastMessage + holder.lastMessageCounterTextView.text = lastMessageCounter + renderNotificationState(holder) + } + + private fun renderNotificationState(holder: Holder) { + when (threadNotificationState) { + ThreadNotificationState.NEW_MESSAGE -> { + holder.unreadImageView.isVisible = true + holder.unreadImageView.setColorFilter(ContextCompat.getColor(holder.view.context, R.color.palette_gray_200)) + } + ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE -> { + holder.unreadImageView.isVisible = true + holder.unreadImageView.setColorFilter(ContextCompat.getColor(holder.view.context, R.color.palette_vermilion)) + } + else -> { + holder.unreadImageView.isVisible = false + } + } + } + + class Holder : VectorEpoxyHolder() { + val avatarImageView by bind(R.id.threadSummaryAvatarImageView) + val titleTextView by bind(R.id.threadSummaryTitleTextView) + val dateTextView by bind(R.id.threadSummaryDateTextView) + val rootMessageTextView by bind(R.id.threadSummaryRootMessageTextView) + val lastMessageAvatarImageView by bind(R.id.messageThreadSummaryAvatarImageView) + val lastMessageCounterTextView by bind(R.id.messageThreadSummaryCounterTextView) + val lastMessageTextView by bind(R.id.messageThreadSummaryInfoTextView) + val unreadImageView by bind(R.id.threadSummaryUnreadImageView) + val rootView by bind(R.id.threadSummaryRootConstraintLayout) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt new file mode 100644 index 0000000000..8bc6bd73e9 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt @@ -0,0 +1,86 @@ +/* + * Copyright 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 im.vector.app.features.home.room.threads.list.viewmodel + +import com.airbnb.epoxy.EpoxyController +import im.vector.app.R +import im.vector.app.core.date.DateFormatKind +import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.resources.StringProvider +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.threads.list.model.threadListItem +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.api.util.toMatrixItem +import javax.inject.Inject + +class ThreadListController @Inject constructor( + private val avatarRenderer: AvatarRenderer, + private val stringProvider: StringProvider, + private val dateFormatter: VectorDateFormatter +) : EpoxyController() { + + var listener: Listener? = null + + private var viewState: ThreadListViewState? = null + + fun update(viewState: ThreadListViewState) { + this.viewState = viewState + requestModelBuild() + } + + override fun buildModels() { + val safeViewState = viewState ?: return + val host = this + + safeViewState.rootThreadEventList.invoke() + ?.filter { + if (safeViewState.shouldFilterThreads) { + it.isParticipating + } else { + true + } + }?.map { + it.timelineEvent + } + ?.forEach { timelineEvent -> + val date = dateFormatter.format(timelineEvent.root.threadDetails?.lastMessageTimestamp, DateFormatKind.ROOM_LIST) + val decryptionErrorMessage = stringProvider.getString(R.string.encrypted_message) + val lastRootThreadEdition = timelineEvent.root.threadDetails?.lastRootThreadEdition + threadListItem { + id(timelineEvent.eventId) + avatarRenderer(host.avatarRenderer) + matrixItem(timelineEvent.senderInfo.toMatrixItem()) + title(timelineEvent.senderInfo.displayName) + date(date) + rootMessageDeleted(timelineEvent.root.isRedacted()) + threadNotificationState(timelineEvent.root.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE) + rootMessage(lastRootThreadEdition ?: timelineEvent.root.getDecryptedTextSummary() ?: decryptionErrorMessage) + lastMessage(timelineEvent.root.threadDetails?.threadSummaryLatestTextMessage ?: decryptionErrorMessage) + lastMessageCounter(timelineEvent.root.threadDetails?.numberOfThreads.toString()) + lastMessageMatrixItem(timelineEvent.root.threadDetails?.threadSummarySenderInfo?.toMatrixItem()) + itemClickListener { + host.listener?.onThreadClicked(timelineEvent) + } + } + } + } + + interface Listener { + fun onThreadClicked(timelineEvent: TimelineEvent) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt new file mode 100644 index 0000000000..d82b5d6ccf --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt @@ -0,0 +1,82 @@ +/* + * Copyright 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 im.vector.app.features.home.room.threads.list.viewmodel + +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.MavericksViewModelFactory +import com.airbnb.mvrx.ViewModelContext +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.platform.EmptyAction +import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.home.room.threads.list.views.ThreadListFragment +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent +import org.matrix.android.sdk.flow.flow + +class ThreadListViewModel @AssistedInject constructor(@Assisted val initialState: ThreadListViewState, + private val session: Session) : + VectorViewModel(initialState) { + + private val room = session.getRoom(initialState.roomId) + + @AssistedFactory + interface Factory { + fun create(initialState: ThreadListViewState): ThreadListViewModel + } + + companion object : MavericksViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: ThreadListViewState): ThreadListViewModel? { + val fragment: ThreadListFragment = (viewModelContext as FragmentViewModelContext).fragment() + return fragment.threadListViewModelFactory.create(state) + } + } + + init { + observeThreadsList() + } + + override fun handle(action: EmptyAction) {} + + private fun observeThreadsList() { + room?.flow() + ?.liveThreadList() + ?.map { room.mapEventsWithEdition(it) } + ?.map { + it.map { threadRootEvent -> + val isParticipating = room.isUserParticipatingInThread(threadRootEvent.eventId) + ThreadTimelineEvent(threadRootEvent, isParticipating) + } + } + ?.flowOn(room.coroutineDispatchers.io) + ?.execute { asyncThreads -> + copy(rootThreadEventList = asyncThreads) + } + } + + fun applyFiltering(shouldFilterThreads: Boolean) { + setState { + copy(shouldFilterThreads = shouldFilterThreads) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt new file mode 100644 index 0000000000..2a70a5be1e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt @@ -0,0 +1,32 @@ +/* + * Copyright 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 im.vector.app.features.home.room.threads.list.viewmodel + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.Uninitialized +import im.vector.app.features.home.room.threads.arguments.ThreadListArgs +import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent + +data class ThreadListViewState( + val rootThreadEventList: Async> = Uninitialized, + val shouldFilterThreads: Boolean = false, + val roomId: String +) : MavericksState { + + constructor(args: ThreadListArgs) : this(roomId = args.roomId) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListBottomSheet.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListBottomSheet.kt new file mode 100644 index 0000000000..7ad4804e5b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListBottomSheet.kt @@ -0,0 +1,85 @@ +/* + * 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 im.vector.app.features.home.room.threads.list.views + +import android.graphics.drawable.Drawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.AttrRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import com.airbnb.mvrx.parentFragmentViewModel +import im.vector.app.R +import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment +import im.vector.app.databinding.BottomSheetThreadListBinding +import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewModel +import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewState +import im.vector.app.features.themes.ThemeUtils + +class ThreadListBottomSheet : VectorBaseBottomSheetDialogFragment() { + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetThreadListBinding { + return BottomSheetThreadListBinding.inflate(inflater, container, false) + } + + private val threadListViewModel: ThreadListViewModel by parentFragmentViewModel() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + threadListViewModel.onEach { + renderState(it) + } + views.threadListModalAllThreads.views.bottomSheetActionClickableZone.debouncedClicks { + threadListViewModel.applyFiltering(false) + dismiss() + } + views.threadListModalMyThreads.views.bottomSheetActionClickableZone.debouncedClicks { + threadListViewModel.applyFiltering(true) + dismiss() + } + } + + private fun renderState(state: ThreadListViewState) { + val radioOffDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_radio_off) + val radioOnDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_radio_on) + + if (state.shouldFilterThreads) { + setRightIconDrawableAllThreads(radioOffDrawable, R.attr.vctr_content_primary) + setRightIconDrawableMyThreads(radioOnDrawable, R.attr.colorPrimary) + } else { + setRightIconDrawableAllThreads(radioOnDrawable, R.attr.colorPrimary) + setRightIconDrawableMyThreads(radioOffDrawable, R.attr.vctr_content_primary) + } + } + + private fun setRightIconDrawableAllThreads(drawable: Drawable?, @AttrRes tint: Int) { + views.threadListModalAllThreads.rightIcon = drawable + views.threadListModalAllThreads.rightIcon?.setTintFromAttribute(tint) + } + + private fun setRightIconDrawableMyThreads(drawable: Drawable?, @AttrRes tint: Int) { + views.threadListModalMyThreads.rightIcon = drawable + views.threadListModalMyThreads.rightIcon?.setTintFromAttribute(tint) + } + + private fun Drawable.setTintFromAttribute(@AttrRes tint: Int) { + DrawableCompat.setTint(this, ThemeUtils.getColor(requireContext(), tint)) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt new file mode 100644 index 0000000000..180e6226d0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt @@ -0,0 +1,122 @@ +/* + * 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 im.vector.app.features.home.room.threads.list.views + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import com.airbnb.mvrx.args +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.extensions.cleanup +import im.vector.app.core.extensions.configureWith +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.databinding.FragmentThreadListBinding +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.timeline.animation.TimelineItemAnimator +import im.vector.app.features.home.room.threads.ThreadsActivity +import im.vector.app.features.home.room.threads.arguments.ThreadListArgs +import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListController +import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewModel +import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewState +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.util.MatrixItem +import javax.inject.Inject + +class ThreadListFragment @Inject constructor( + private val avatarRenderer: AvatarRenderer, + private val threadListController: ThreadListController, + val threadListViewModelFactory: ThreadListViewModel.Factory +) : VectorBaseFragment(), + ThreadListController.Listener { + + private val threadListViewModel: ThreadListViewModel by fragmentViewModel() + + private val threadListArgs: ThreadListArgs by args() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentThreadListBinding { + return FragmentThreadListBinding.inflate(inflater, container, false) + } + + override fun getMenuRes() = R.menu.menu_thread_list + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.menu_thread_list_filter -> { + ThreadListBottomSheet().show(childFragmentManager, "Filtering") + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initToolbar() + initTextConstants() + views.threadListRecyclerView.configureWith(threadListController, TimelineItemAnimator(), hasFixedSize = false) + threadListController.listener = this + } + + override fun onDestroyView() { + views.threadListRecyclerView.cleanup() + threadListController.listener = null + super.onDestroyView() + } + + private fun initToolbar() { + setupToolbar(views.threadListToolbar).allowBack() + renderToolbar() + } + + private fun initTextConstants() { + views.threadListEmptyNoticeTextView.text = String.format( + resources.getString(R.string.thread_list_empty_notice), + resources.getString(R.string.reply_in_thread)) + } + + override fun invalidate() = withState(threadListViewModel) { state -> + renderEmptyStateIfNeeded(state) + threadListController.update(state) + } + + private fun renderToolbar() { + views.includeThreadListToolbar.roomToolbarThreadConstraintLayout.isVisible = true + val matrixItem = MatrixItem.RoomItem(threadListArgs.roomId, threadListArgs.displayName, threadListArgs.avatarUrl) + avatarRenderer.render(matrixItem, views.includeThreadListToolbar.roomToolbarThreadImageView) + views.includeThreadListToolbar.roomToolbarThreadShieldImageView.render(threadListArgs.roomEncryptionTrustLevel) + views.includeThreadListToolbar.roomToolbarThreadTitleTextView.text = resources.getText(R.string.thread_list_title) + views.includeThreadListToolbar.roomToolbarThreadSubtitleTextView.text = threadListArgs.displayName + } + + override fun onThreadClicked(timelineEvent: TimelineEvent) { + (activity as? ThreadsActivity)?.navigateToThreadTimeline(timelineEvent) + } + + private fun renderEmptyStateIfNeeded(state: ThreadListViewState) { + val show = state.rootThreadEventList.invoke().isNullOrEmpty() + views.threadListEmptyConstraintLayout.isVisible = show + } +} diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index 7062f6a97e..b521710c1e 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -53,10 +53,13 @@ import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.debug.DebugMenuActivity import im.vector.app.features.devtools.RoomDevToolActivity import im.vector.app.features.home.room.detail.RoomDetailActivity -import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.home.room.detail.search.SearchActivity import im.vector.app.features.home.room.detail.search.SearchArgs import im.vector.app.features.home.room.filtered.FilteredRoomsActivity +import im.vector.app.features.home.room.threads.ThreadsActivity +import im.vector.app.features.home.room.threads.arguments.ThreadListArgs +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.invite.InviteUsersToRoomActivity import im.vector.app.features.location.LocationData import im.vector.app.features.location.LocationSharingActivity @@ -140,12 +143,17 @@ class DefaultNavigator @Inject constructor( context.startActivity(intent) } - override fun openRoom(context: Context, roomId: String, eventId: String?, buildTask: Boolean) { + override fun openRoom( + context: Context, + roomId: String, + eventId: String?, + buildTask: Boolean + ) { if (sessionHolder.getSafeActiveSession()?.getRoom(roomId) == null) { fatalError("Trying to open an unknown room $roomId", vectorPreferences.failFast()) return } - val args = RoomDetailArgs(roomId, eventId) + val args = TimelineArgs(roomId, eventId) val intent = RoomDetailActivity.newIntent(context, args) startActivity(context, intent, buildTask) } @@ -168,7 +176,7 @@ class DefaultNavigator @Inject constructor( startActivity(context, SpaceManageActivity.newIntent(context, spaceId, ManageType.AddRooms), false) } is Navigator.PostSwitchSpaceAction.OpenDefaultRoom -> { - val args = RoomDetailArgs( + val args = TimelineArgs( postSwitchSpaceAction.roomId, eventId = null, openShareSpaceForId = spaceId.takeIf { postSwitchSpaceAction.showShareSheet } @@ -266,7 +274,7 @@ class DefaultNavigator @Inject constructor( } override fun openRoomForSharingAndFinish(activity: Activity, roomId: String, sharedData: SharedData) { - val args = RoomDetailArgs(roomId, null, sharedData) + val args = TimelineArgs(roomId, null, sharedData) val intent = RoomDetailActivity.newIntent(activity, args) activity.startActivity(intent) activity.finish() @@ -515,8 +523,11 @@ class DefaultNavigator @Inject constructor( } } - override fun openSearch(context: Context, roomId: String) { - val intent = SearchActivity.newIntent(context, SearchArgs(roomId)) + override fun openSearch(context: Context, + roomId: String, + roomDisplayName: String?, + roomAvatarUrl: String?) { + val intent = SearchActivity.newIntent(context, SearchArgs(roomId, roomDisplayName, roomAvatarUrl)) context.startActivity(intent) } @@ -562,4 +573,25 @@ class DefaultNavigator @Inject constructor( context.startActivity(intent) } } + + override fun openThread(context: Context, threadTimelineArgs: ThreadTimelineArgs, eventIdToNavigate: String?) { + context.startActivity(ThreadsActivity.newIntent( + context = context, + threadTimelineArgs = threadTimelineArgs, + threadListArgs = null, + eventIdToNavigate = eventIdToNavigate + )) + } + + override fun openThreadList(context: Context, threadTimelineArgs: ThreadTimelineArgs) { + context.startActivity(ThreadsActivity.newIntent( + context = context, + threadTimelineArgs = null, + threadListArgs = ThreadListArgs( + roomId = threadTimelineArgs.roomId, + displayName = threadTimelineArgs.displayName, + avatarUrl = threadTimelineArgs.avatarUrl, + roomEncryptionTrustLevel = threadTimelineArgs.roomEncryptionTrustLevel + ))) + } } diff --git a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt index 275a1f214e..b5e94241ce 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt @@ -25,6 +25,7 @@ import androidx.activity.result.ActivityResultLauncher import androidx.core.util.Pair import im.vector.app.features.crypto.recover.SetupMode import im.vector.app.features.displayname.getBestName +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.location.LocationData import im.vector.app.features.location.LocationSharingMode import im.vector.app.features.login.LoginConfig @@ -145,7 +146,7 @@ interface Navigator { inMemory: List = emptyList(), options: ((MutableList>) -> Unit)?) - fun openSearch(context: Context, roomId: String) + fun openSearch(context: Context, roomId: String, roomDisplayName: String?, roomAvatarUrl: String?) fun openDevTools(context: Context, roomId: String) @@ -162,4 +163,8 @@ interface Navigator { mode: LocationSharingMode, initialLocationData: LocationData?, locationOwnerId: String?) + + fun openThread(context: Context, threadTimelineArgs: ThreadTimelineArgs, eventIdToNavigate: String? = null) + + fun openThreadList(context: Context, threadTimelineArgs: ThreadTimelineArgs) } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt index 27568dae95..d39926f620 100755 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt @@ -58,7 +58,7 @@ import im.vector.app.features.call.webrtc.WebRtcCall import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.HomeActivity import im.vector.app.features.home.room.detail.RoomDetailActivity -import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.troubleshoot.TestNotificationReceiver import im.vector.app.features.themes.ThemeUtils @@ -514,7 +514,7 @@ class NotificationUtils @Inject constructor(private val context: Context, val contentPendingIntent = TaskStackBuilder.create(context) .addNextIntentWithParentStack(HomeActivity.newIntent(context)) - .addNextIntent(RoomDetailActivity.newIntent(context, RoomDetailArgs(callInformation.nativeRoomId))) + .addNextIntent(RoomDetailActivity.newIntent(context, TimelineArgs(callInformation.nativeRoomId))) .getPendingIntent(System.currentTimeMillis().toInt(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE) builder.setContentIntent(contentPendingIntent) @@ -773,7 +773,7 @@ class NotificationUtils @Inject constructor(private val context: Context, } private fun buildOpenRoomIntent(roomId: String): PendingIntent? { - val roomIntentTap = RoomDetailActivity.newIntent(context, RoomDetailArgs(roomId = roomId, switchToParentSpace = true)) + val roomIntentTap = RoomDetailActivity.newIntent(context, TimelineArgs(roomId = roomId, switchToParentSpace = true)) roomIntentTap.action = TAP_TO_VIEW_ACTION // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that roomIntentTap.data = createIgnoredUri("openRoom?$roomId") diff --git a/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt b/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt index 40cc0b3e13..87cbf44f04 100644 --- a/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt +++ b/vector/src/main/java/im/vector/app/features/permalink/PermalinkHandler.kt @@ -23,16 +23,19 @@ import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.extensions.isIgnored import im.vector.app.core.utils.toast +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.navigation.Navigator import im.vector.app.features.roomdirectory.roompreview.RoomPreviewData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId import org.matrix.android.sdk.api.session.permalinks.PermalinkData import org.matrix.android.sdk.api.session.permalinks.PermalinkParser import org.matrix.android.sdk.api.session.permalinks.PermalinkService import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.RoomType import javax.inject.Inject @@ -80,13 +83,20 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti return when (permalinkData) { is PermalinkData.RoomLink -> { val roomId = permalinkData.getRoomId() + val session = activeSessionHolder.getSafeActiveSession() + + val rootThreadEventId = permalinkData.eventId?.let { eventId -> + val room = roomId?.let { session?.getRoom(it) } + room?.getTimeLineEvent(eventId)?.root?.getRootThreadEventId() + } openRoom( navigationInterceptor, context = context, roomId = roomId, permalinkData = permalinkData, rawLink = rawLink, - buildTask = buildTask + buildTask = buildTask, + rootThreadEventId = rootThreadEventId ) true } @@ -150,7 +160,8 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti roomId: String?, permalinkData: PermalinkData.RoomLink, rawLink: Uri, - buildTask: Boolean + buildTask: Boolean, + rootThreadEventId: String? = null ) { val session = activeSessionHolder.getSafeActiveSession() ?: return if (roomId == null) { @@ -167,7 +178,7 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti membership?.isActive().orFalse() -> { if (!isSpace && membership == Membership.JOIN) { // If it's a room you're in, let's just open it, you can tap back if needed - navigationInterceptor.openJoinedRoomScreen(buildTask, roomId, eventId, rawLink, context) + navigationInterceptor.openJoinedRoomScreen(buildTask, roomId, eventId, rawLink, context, rootThreadEventId, roomSummary) } else { // maybe open space preview navigator.openSpacePreview(context, roomId)? if already joined? navigator.openMatrixToBottomSheet(context, rawLink.toString()) @@ -180,9 +191,24 @@ class PermalinkHandler @Inject constructor(private val activeSessionHolder: Acti } } - private fun NavigationInterceptor?.openJoinedRoomScreen(buildTask: Boolean, roomId: String, eventId: String?, rawLink: Uri, context: Context) { - if (this?.navToRoom(roomId, eventId, rawLink) != true) { - navigator.openRoom(context, roomId, eventId, buildTask) + private fun NavigationInterceptor?.openJoinedRoomScreen(buildTask: Boolean, + roomId: String, + eventId: String?, + rawLink: Uri, + context: Context, + rootThreadEventId: String?, + roomSummary: RoomSummary + ) { + if (this?.navToRoom(roomId, eventId, rawLink, rootThreadEventId) != true) { + rootThreadEventId?.let { + val threadTimelineArgs = ThreadTimelineArgs( + roomId = roomId, + displayName = roomSummary.displayName, + avatarUrl = roomSummary.avatarUrl, + roomEncryptionTrustLevel = roomSummary.roomEncryptionTrustLevel, + rootThreadEventId = it) + navigator.openThread(context, threadTimelineArgs, eventId) + } ?: navigator.openRoom(context, roomId, eventId, buildTask) } } @@ -198,7 +224,7 @@ interface NavigationInterceptor { /** * Return true if the navigation has been intercepted */ - fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri? = null): Boolean { + fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri? = null, rootThreadEventId: String? = null): Boolean { return false } diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerAction.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerAction.kt new file mode 100644 index 0000000000..910f0246d3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerAction.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.qrcode + +import im.vector.app.core.platform.VectorViewModelAction + +sealed class QrCodeScannerAction : VectorViewModelAction { + data class CodeDecoded( + val result: String, + val isQrCode: Boolean + ) : QrCodeScannerAction() + + object ScanFailed : QrCodeScannerAction() + + object SwitchMode : QrCodeScannerAction() +} diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerActivity.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerActivity.kt index d347bc0250..dda7b2e2eb 100644 --- a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerActivity.kt +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerActivity.kt @@ -19,57 +19,55 @@ package im.vector.app.features.qrcode import android.app.Activity import android.content.Intent import android.os.Bundle +import android.widget.Toast import androidx.activity.result.ActivityResultLauncher -import com.google.zxing.BarcodeFormat -import com.google.zxing.Result -import com.google.zxing.ResultMetadataType +import com.airbnb.mvrx.viewModel import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.core.extensions.exhaustive import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivitySimpleBinding @AndroidEntryPoint -class QrCodeScannerActivity : VectorBaseActivity() { +class QrCodeScannerActivity() : VectorBaseActivity() { override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater) override fun getCoordinatorLayout() = views.coordinatorLayout + private val qrViewModel: QrCodeScannerViewModel by viewModel() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + qrViewModel.observeViewEvents { + when (it) { + is QrCodeScannerEvents.CodeParsed -> { + setResultAndFinish(it.result, it.isQrCode) + } + is QrCodeScannerEvents.ParseFailed -> { + Toast.makeText(this, R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show() + finish() + } + else -> Unit + }.exhaustive + } + if (isFirstCreation()) { - replaceFragment(views.simpleFragmentContainer, QrCodeScannerFragment::class.java) + val args = QrScannerArgs(showExtraButtons = false, R.string.verification_scan_their_code) + replaceFragment(views.simpleFragmentContainer, QrCodeScannerFragment::class.java, args) } } - fun setResultAndFinish(result: Result?) { - if (result != null) { - val rawBytes = getRawBytes(result) - val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1) - - setResult(RESULT_OK, Intent().apply { - putExtra(EXTRA_OUT_TEXT, rawBytesStr ?: result.text) - putExtra(EXTRA_OUT_IS_QR_CODE, result.barcodeFormat == BarcodeFormat.QR_CODE) - }) - } + private fun setResultAndFinish(result: String, isQrCode: Boolean) { + setResult(RESULT_OK, Intent().apply { + putExtra(EXTRA_OUT_TEXT, result) + putExtra(EXTRA_OUT_IS_QR_CODE, isQrCode) + }) finish() } - // Copied from https://github.com/markusfisch/BinaryEye/blob/ - // 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434 - private fun getRawBytes(result: Result): ByteArray? { - val metadata = result.resultMetadata ?: return null - val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null - var bytes = ByteArray(0) - @Suppress("UNCHECKED_CAST") - for (seg in segments as Iterable) { - bytes += seg - } - // byte segments can never be shorter than the text. - // Zxing cuts off content prefixes like "WIFI:" - return if (bytes.size >= result.text.length) bytes else null - } - companion object { private const val EXTRA_OUT_TEXT = "EXTRA_OUT_TEXT" private const val EXTRA_OUT_IS_QR_CODE = "EXTRA_OUT_IS_QR_CODE" diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerEvents.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerEvents.kt new file mode 100644 index 0000000000..69a500238e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerEvents.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.qrcode + +import im.vector.app.core.platform.VectorViewEvents + +sealed class QrCodeScannerEvents : VectorViewEvents { + data class CodeParsed(val result: String, val isQrCode: Boolean) : QrCodeScannerEvents() + object ParseFailed : QrCodeScannerEvents() + object SwitchMode : QrCodeScannerEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerFragment.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerFragment.kt index a7231a0c5b..c514a1c8aa 100644 --- a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerFragment.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 New Vector Ltd + * Copyright (c) 2022 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,50 +16,157 @@ package im.vector.app.features.qrcode +import android.app.Activity import android.os.Bundle +import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.core.view.isVisible +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.args +import com.google.zxing.BarcodeFormat import com.google.zxing.Result +import com.google.zxing.ResultMetadataType import im.vector.app.R +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO +import im.vector.app.core.utils.checkPermissions +import im.vector.app.core.utils.onPermissionDeniedDialog +import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.databinding.FragmentQrCodeScannerBinding +import im.vector.app.features.usercode.QRCodeBitmapDecodeHelper +import im.vector.lib.multipicker.MultiPicker +import im.vector.lib.multipicker.utils.ImageUtils +import kotlinx.parcelize.Parcelize import me.dm7.barcodescanner.zxing.ZXingScannerView +import org.matrix.android.sdk.api.extensions.tryOrNull import javax.inject.Inject -class QrCodeScannerFragment @Inject constructor() : - VectorBaseFragment(), - ZXingScannerView.ResultHandler { +@Parcelize +data class QrScannerArgs( + val showExtraButtons: Boolean, + @StringRes val titleRes: Int +) : Parcelable + +open class QrCodeScannerFragment @Inject constructor() : VectorBaseFragment(), ZXingScannerView.ResultHandler { + + private val qrViewModel: QrCodeScannerViewModel by activityViewModel() + private val scannerArgs: QrScannerArgs? by args() override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentQrCodeScannerBinding { return FragmentQrCodeScannerBinding.inflate(inflater, container, false) } + private val openCameraActivityResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> + if (allGranted) { + startCamera() + } else if (deniedPermanently) { + activity?.onPermissionDeniedDialog(R.string.denied_permission_camera) + } + } + + private val pickImageActivityResultLauncher = registerStartForActivityResult { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + MultiPicker + .get(MultiPicker.IMAGE) + .getSelectedFiles(requireActivity(), activityResult.data) + .firstOrNull() + ?.contentUri + ?.let { uri -> + // try to see if it is a valid matrix code + val bitmap = ImageUtils.getBitmap(requireContext(), uri) + ?: return@let Unit.also { + Toast.makeText(requireContext(), getString(R.string.qr_code_not_scanned), Toast.LENGTH_SHORT).show() + } + handleResult(tryOrNull { QRCodeBitmapDecodeHelper.decodeQRFromBitmap(bitmap) }) + } + } + } + + private var autoFocus = true + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + val title = scannerArgs?.titleRes?.let { getString(it) } + setupToolbar(views.qrScannerToolbar) - .setTitle(R.string.verification_scan_their_code) + .setTitle(title) .allowBack(useCross = true) + + scannerArgs?.showExtraButtons?.let { showButtons -> + views.userCodeMyCodeButton.isVisible = showButtons + views.userCodeOpenGalleryButton.isVisible = showButtons + + if (showButtons) { + views.userCodeOpenGalleryButton.debouncedClicks { + MultiPicker.get(MultiPicker.IMAGE).single().startWith(pickImageActivityResultLauncher) + } + views.userCodeMyCodeButton.debouncedClicks { + qrViewModel.handle(QrCodeScannerAction.SwitchMode) + } + } + } + } + + private fun startCamera() { + with(views.qrScannerView) { + startCamera() + setAutoFocus(autoFocus) + debouncedClicks { + autoFocus = !autoFocus + setAutoFocus(autoFocus) + } + } } override fun onResume() { super.onResume() + view?.hideKeyboard() + // Register ourselves as a handler for scan results. - views.scannerView.setResultHandler(this) - // Start camera on resume - views.scannerView.startCamera() + views.qrScannerView.setResultHandler(this) + + if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), openCameraActivityResultLauncher)) { + startCamera() + } } override fun onPause() { super.onPause() - // Stop camera on pause - views.scannerView.stopCamera() + views.qrScannerView.setResultHandler(null) + views.qrScannerView.stopCamera() + } + + // Copied from https://github.com/markusfisch/BinaryEye/blob/ + // 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434 + private fun getRawBytes(result: Result): ByteArray? { + val metadata = result.resultMetadata ?: return null + val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null + var bytes = ByteArray(0) + @Suppress("UNCHECKED_CAST") + for (seg in segments as Iterable) { + bytes += seg + } + // byte segments can never be shorter than the text. + // Zxing cuts off content prefixes like "WIFI:" + return if (bytes.size >= result.text.length) bytes else null } override fun handleResult(rawResult: Result?) { - // Do something with the result here - // This is not intended to be used outside of QrCodeScannerActivity for the moment - (requireActivity() as? QrCodeScannerActivity)?.setResultAndFinish(rawResult) + if (rawResult == null) { + qrViewModel.handle(QrCodeScannerAction.ScanFailed) + } else { + val rawBytes = getRawBytes(rawResult) + val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1) + val result = rawBytesStr ?: rawResult.text + val isQrCode = rawResult.barcodeFormat == BarcodeFormat.QR_CODE + qrViewModel.handle(QrCodeScannerAction.CodeDecoded(result, isQrCode)) + } } } diff --git a/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerViewModel.kt b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerViewModel.kt new file mode 100644 index 0000000000..ef47ea1a6e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/qrcode/QrCodeScannerViewModel.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.qrcode + +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.VectorDummyViewState +import im.vector.app.core.platform.VectorViewModel +import org.matrix.android.sdk.api.session.Session + +class QrCodeScannerViewModel @AssistedInject constructor( + @Assisted initialState: VectorDummyViewState, + val session: Session +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: VectorDummyViewState): QrCodeScannerViewModel + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + override fun handle(action: QrCodeScannerAction) { + _viewEvents.post( + when (action) { + is QrCodeScannerAction.CodeDecoded -> QrCodeScannerEvents.CodeParsed(action.result, action.isQrCode) + is QrCodeScannerAction.SwitchMode -> QrCodeScannerEvents.SwitchMode + is QrCodeScannerAction.ScanFailed -> QrCodeScannerEvents.ParseFailed + } + ) + } +} diff --git a/vector/src/main/java/im/vector/app/features/room/RequireActiveMembershipViewState.kt b/vector/src/main/java/im/vector/app/features/room/RequireActiveMembershipViewState.kt index 7a5363100f..7e4af1b7d5 100644 --- a/vector/src/main/java/im/vector/app/features/room/RequireActiveMembershipViewState.kt +++ b/vector/src/main/java/im/vector/app/features/room/RequireActiveMembershipViewState.kt @@ -17,7 +17,7 @@ package im.vector.app.features.room import com.airbnb.mvrx.MavericksState -import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.arguments.TimelineArgs import im.vector.app.features.roommemberprofile.RoomMemberProfileArgs import im.vector.app.features.roomprofile.RoomProfileArgs @@ -25,7 +25,7 @@ data class RequireActiveMembershipViewState( val roomId: String? = null ) : MavericksState { - constructor(args: RoomDetailArgs) : this(roomId = args.roomId) + constructor(args: TimelineArgs) : this(roomId = args.roomId) constructor(args: RoomProfileArgs) : this(roomId = args.roomId) diff --git a/vector/src/main/java/im/vector/app/features/roomdirectory/PublicRoomsFragment.kt b/vector/src/main/java/im/vector/app/features/roomdirectory/PublicRoomsFragment.kt index b6b8aa9653..14b50c2745 100644 --- a/vector/src/main/java/im/vector/app/features/roomdirectory/PublicRoomsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomdirectory/PublicRoomsFragment.kt @@ -127,7 +127,7 @@ class PublicRoomsFragment @Inject constructor( val permalink = session.permalinkService().createPermalink(roomIdOrAlias) val isHandled = permalinkHandler .launch(requireContext(), permalink, object : NavigationInterceptor { - override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?): Boolean { + override fun navToRoom(roomId: String?, eventId: String?, deepLink: Uri?, rootThreadEventId: String?): Boolean { requireActivity().finish() return false } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index eb620f8e5c..1903b3776a 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -200,6 +200,7 @@ class VectorPreferences @Inject constructor(private val context: Context) { private const val TAKE_PHOTO_VIDEO_MODE = "TAKE_PHOTO_VIDEO_MODE" private const val SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE = "SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE" + const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES" // Possible values for TAKE_PHOTO_VIDEO_MODE const val TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK = 0 @@ -1002,4 +1003,8 @@ class VectorPreferences @Inject constructor(private val context: Context) { fun labsRenderLocationsInTimeline(): Boolean { return defaultPrefs.getBoolean(SETTINGS_LABS_RENDER_LOCATIONS_IN_TIMELINE, true) } + + fun areThreadMessagesEnabled(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_THREAD_MESSAGES, false) + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt index a83b4c33f4..118e820f84 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsLabsFragment.kt @@ -16,12 +16,18 @@ package im.vector.app.features.settings +import androidx.preference.Preference import im.vector.app.R import im.vector.app.core.preference.VectorSwitchPreference +import im.vector.app.features.MainActivity +import im.vector.app.features.MainActivityArgs +import org.matrix.android.sdk.internal.database.lightweight.LightweightSettingsStorage import javax.inject.Inject class VectorSettingsLabsFragment @Inject constructor( - private val vectorPreferences: VectorPreferences + private val vectorPreferences: VectorPreferences, + private val lightweightSettingsStorage: LightweightSettingsStorage + ) : VectorSettingsBaseFragment() { override var titleRes = R.string.room_settings_labs_pref_title @@ -32,5 +38,15 @@ class VectorSettingsLabsFragment @Inject constructor( // ensure correct default pref.isChecked = vectorPreferences.labsAutoReportUISI() } + + // clear cache + findPreference(VectorPreferences.SETTINGS_LABS_ENABLE_THREAD_MESSAGES)?.let { + it.onPreferenceClickListener = Preference.OnPreferenceClickListener { + lightweightSettingsStorage.setThreadMessagesEnabled(vectorPreferences.areThreadMessagesEnabled()) + displayLoadingView() + MainActivity.restartApp(requireActivity(), MainActivityArgs(clearCache = true)) + false + } + } } } diff --git a/vector/src/main/java/im/vector/app/features/usercode/ScanUserCodeFragment.kt b/vector/src/main/java/im/vector/app/features/usercode/ScanUserCodeFragment.kt deleted file mode 100644 index a7d632bd7b..0000000000 --- a/vector/src/main/java/im/vector/app/features/usercode/ScanUserCodeFragment.kt +++ /dev/null @@ -1,156 +0,0 @@ -/* - * Copyright (c) 2020 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.usercode - -import android.Manifest -import android.app.Activity -import android.content.pm.PackageManager -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.core.content.ContextCompat -import com.airbnb.mvrx.activityViewModel -import com.google.zxing.Result -import com.google.zxing.ResultMetadataType -import im.vector.app.R -import im.vector.app.core.extensions.registerStartForActivityResult -import im.vector.app.core.platform.VectorBaseFragment -import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO -import im.vector.app.core.utils.checkPermissions -import im.vector.app.core.utils.registerForPermissionsResult -import im.vector.app.databinding.FragmentQrCodeScannerWithButtonBinding -import im.vector.lib.multipicker.MultiPicker -import im.vector.lib.multipicker.utils.ImageUtils -import me.dm7.barcodescanner.zxing.ZXingScannerView -import org.matrix.android.sdk.api.extensions.tryOrNull -import javax.inject.Inject - -class ScanUserCodeFragment @Inject constructor() : - VectorBaseFragment(), - ZXingScannerView.ResultHandler { - - override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentQrCodeScannerWithButtonBinding { - return FragmentQrCodeScannerWithButtonBinding.inflate(inflater, container, false) - } - - val sharedViewModel: UserCodeSharedViewModel by activityViewModel() - - var autoFocus = true - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupToolbar(views.qrScannerToolbar) - .allowBack(useCross = true) - - views.userCodeMyCodeButton.debouncedClicks { - sharedViewModel.handle(UserCodeActions.SwitchMode(UserCodeState.Mode.SHOW)) - } - - views.userCodeOpenGalleryButton.debouncedClicks { - MultiPicker.get(MultiPicker.IMAGE).single().startWith(pickImageActivityResultLauncher) - } - } - - private val openCameraActivityResultLauncher = registerForPermissionsResult { allGranted, _ -> - if (allGranted) { - startCamera() - } else { - // For now just go back - sharedViewModel.handle(UserCodeActions.SwitchMode(UserCodeState.Mode.SHOW)) - } - } - - private val pickImageActivityResultLauncher = registerStartForActivityResult { activityResult -> - if (activityResult.resultCode == Activity.RESULT_OK) { - MultiPicker - .get(MultiPicker.IMAGE) - .getSelectedFiles(requireActivity(), activityResult.data) - .firstOrNull() - ?.contentUri - ?.let { uri -> - // try to see if it is a valid matrix code - val bitmap = ImageUtils.getBitmap(requireContext(), uri) - ?: return@let Unit.also { - Toast.makeText(requireContext(), getString(R.string.qr_code_not_scanned), Toast.LENGTH_SHORT).show() - } - handleResult(tryOrNull { QRCodeBitmapDecodeHelper.decodeQRFromBitmap(bitmap) }) - } - } - } - - private fun startCamera() { - views.userCodeScannerView.startCamera() - views.userCodeScannerView.setAutoFocus(autoFocus) - views.userCodeScannerView.debouncedClicks { - this.autoFocus = !autoFocus - views.userCodeScannerView.setAutoFocus(autoFocus) - } - } - - override fun onStart() { - super.onStart() - if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), openCameraActivityResultLauncher)) { - startCamera() - } - } - - override fun onResume() { - super.onResume() - // Register ourselves as a handler for scan results. - views.userCodeScannerView.setResultHandler(this) - if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { - startCamera() - } - } - - override fun onPause() { - super.onPause() - views.userCodeScannerView.setResultHandler(null) - // Stop camera on pause - views.userCodeScannerView.stopCamera() - } - - override fun handleResult(result: Result?) { - if (result === null) { - Toast.makeText(requireContext(), R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show() - requireActivity().finish() - } else { - val rawBytes = getRawBytes(result) - val rawBytesStr = rawBytes?.toString(Charsets.ISO_8859_1) - val value = rawBytesStr ?: result.text - sharedViewModel.handle(UserCodeActions.DecodedQRCode(value)) - } - } - - // Copied from https://github.com/markusfisch/BinaryEye/blob/ - // 9d57889b810dcaa1a91d7278fc45c262afba1284/app/src/main/kotlin/de/markusfisch/android/binaryeye/activity/CameraActivity.kt#L434 - private fun getRawBytes(result: Result): ByteArray? { - val metadata = result.resultMetadata ?: return null - val segments = metadata[ResultMetadataType.BYTE_SEGMENTS] ?: return null - var bytes = ByteArray(0) - @Suppress("UNCHECKED_CAST") - for (seg in segments as Iterable) { - bytes += seg - } - // byte segments can never be shorter than the text. - // Zxing cuts off content prefixes like "WIFI:" - return if (bytes.size >= result.text.length) bytes else null - } -} diff --git a/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt b/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt index 7011f8c280..356893aee2 100644 --- a/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt +++ b/vector/src/main/java/im/vector/app/features/usercode/UserCodeActivity.kt @@ -30,12 +30,16 @@ import com.airbnb.mvrx.viewModel import com.airbnb.mvrx.withState import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R -import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.utils.onPermissionDeniedSnackbar import im.vector.app.databinding.ActivitySimpleBinding import im.vector.app.features.matrixto.MatrixToBottomSheet +import im.vector.app.features.qrcode.QrCodeScannerEvents +import im.vector.app.features.qrcode.QrCodeScannerFragment +import im.vector.app.features.qrcode.QrCodeScannerViewModel +import im.vector.app.features.qrcode.QrScannerArgs import kotlinx.parcelize.Parcelize import kotlin.reflect.KClass @@ -44,6 +48,7 @@ class UserCodeActivity : VectorBaseActivity(), MatrixToBottomSheet.InteractionListener { val sharedViewModel: UserCodeSharedViewModel by viewModel() + private val qrViewModel: QrCodeScannerViewModel by viewModel() @Parcelize data class Args( @@ -81,10 +86,13 @@ class UserCodeActivity : VectorBaseActivity(), sharedViewModel.onEach(UserCodeState::mode) { mode -> when (mode) { - UserCodeState.Mode.SHOW -> showFragment(ShowUserCodeFragment::class, Bundle.EMPTY) - UserCodeState.Mode.SCAN -> showFragment(ScanUserCodeFragment::class, Bundle.EMPTY) + UserCodeState.Mode.SHOW -> showFragment(ShowUserCodeFragment::class) + UserCodeState.Mode.SCAN -> { + val args = QrScannerArgs(showExtraButtons = true, R.string.user_code_scan) + showFragment(QrCodeScannerFragment::class, args) + } is UserCodeState.Mode.RESULT -> { - showFragment(ShowUserCodeFragment::class, Bundle.EMPTY) + showFragment(ShowUserCodeFragment::class) MatrixToBottomSheet.withLink(mode.rawLink).show(supportFragmentManager, "MatrixToBottomSheet") } } @@ -106,6 +114,21 @@ class UserCodeActivity : VectorBaseActivity(), } } } + + qrViewModel.observeViewEvents { + when (it) { + is QrCodeScannerEvents.CodeParsed -> { + sharedViewModel.handle(UserCodeActions.DecodedQRCode(it.result)) + } + QrCodeScannerEvents.SwitchMode -> { + sharedViewModel.handle(UserCodeActions.SwitchMode(UserCodeState.Mode.SHOW)) + } + is QrCodeScannerEvents.ParseFailed -> { + Toast.makeText(this, R.string.qr_code_not_scanned, Toast.LENGTH_SHORT).show() + finish() + } + }.exhaustive + } } override fun onDestroy() { @@ -113,16 +136,9 @@ class UserCodeActivity : VectorBaseActivity(), super.onDestroy() } - private fun showFragment(fragmentClass: KClass, bundle: Bundle) { + private fun showFragment(fragmentClass: KClass, params: Parcelable? = null) { if (supportFragmentManager.findFragmentByTag(fragmentClass.simpleName) == null) { - supportFragmentManager.commitTransaction { - setCustomAnimations(R.anim.fade_in, R.anim.fade_out, R.anim.fade_in, R.anim.fade_out) - replace(views.simpleFragmentContainer.id, - fragmentClass.java, - bundle, - fragmentClass.simpleName - ) - } + replaceFragment(views.simpleFragmentContainer, fragmentClass.java, params, fragmentClass.simpleName, useCustomAnimation = true) } } diff --git a/vector/src/main/res/drawable/ic_filter.xml b/vector/src/main/res/drawable/ic_filter.xml index 740585b17e..35fd8db97d 100644 --- a/vector/src/main/res/drawable/ic_filter.xml +++ b/vector/src/main/res/drawable/ic_filter.xml @@ -4,17 +4,6 @@ android:viewportWidth="24" android:viewportHeight="24"> - + android:pathData="M10.9996,18H12.9996C13.5496,18 13.9996,17.55 13.9996,17C13.9996,16.45 13.5496,16 12.9996,16H10.9996C10.4496,16 9.9996,16.45 9.9996,17C9.9996,17.55 10.4496,18 10.9996,18ZM2.9996,7C2.9996,7.55 3.4496,8 3.9996,8H19.9996C20.5496,8 20.9996,7.55 20.9996,7C20.9996,6.45 20.5496,6 19.9996,6H3.9996C3.4496,6 2.9996,6.45 2.9996,7ZM6.9996,13H16.9996C17.5496,13 17.9996,12.55 17.9996,12C17.9996,11.45 17.5496,11 16.9996,11H6.9996C6.4496,11 5.9996,11.45 5.9996,12C5.9996,12.55 6.4496,13 6.9996,13Z" + android:fillColor="#737D8C"/> diff --git a/vector/src/main/res/drawable/ic_reply_in_thread.xml b/vector/src/main/res/drawable/ic_reply_in_thread.xml new file mode 100644 index 0000000000..3b9b595bd3 --- /dev/null +++ b/vector/src/main/res/drawable/ic_reply_in_thread.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/vector/src/main/res/drawable/ic_thread_link_menu_item.xml b/vector/src/main/res/drawable/ic_thread_link_menu_item.xml new file mode 100644 index 0000000000..779c9d832c --- /dev/null +++ b/vector/src/main/res/drawable/ic_thread_link_menu_item.xml @@ -0,0 +1,12 @@ + + + diff --git a/vector/src/main/res/drawable/ic_thread_menu_item.xml b/vector/src/main/res/drawable/ic_thread_menu_item.xml new file mode 100644 index 0000000000..2d77251c53 --- /dev/null +++ b/vector/src/main/res/drawable/ic_thread_menu_item.xml @@ -0,0 +1,10 @@ + + + diff --git a/vector/src/main/res/drawable/ic_thread_share_menu_item.xml b/vector/src/main/res/drawable/ic_thread_share_menu_item.xml new file mode 100644 index 0000000000..cb863c39bf --- /dev/null +++ b/vector/src/main/res/drawable/ic_thread_share_menu_item.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/vector/src/main/res/drawable/ic_thread_summary.xml b/vector/src/main/res/drawable/ic_thread_summary.xml new file mode 100644 index 0000000000..5e27ad0a0a --- /dev/null +++ b/vector/src/main/res/drawable/ic_thread_summary.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/vector/src/main/res/drawable/ic_thread_view_in_room_menu_item.xml b/vector/src/main/res/drawable/ic_thread_view_in_room_menu_item.xml new file mode 100644 index 0000000000..f408f99713 --- /dev/null +++ b/vector/src/main/res/drawable/ic_thread_view_in_room_menu_item.xml @@ -0,0 +1,30 @@ + + + + + + diff --git a/vector/src/main/res/drawable/notification_badge.xml b/vector/src/main/res/drawable/notification_badge.xml new file mode 100644 index 0000000000..11f4b1d274 --- /dev/null +++ b/vector/src/main/res/drawable/notification_badge.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/activity_threads.xml b/vector/src/main/res/layout/activity_threads.xml new file mode 100644 index 0000000000..c34be9687d --- /dev/null +++ b/vector/src/main/res/layout/activity_threads.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/bottom_sheet_thread_list.xml b/vector/src/main/res/layout/bottom_sheet_thread_list.xml new file mode 100644 index 0000000000..e736f30edc --- /dev/null +++ b/vector/src/main/res/layout/bottom_sheet_thread_list.xml @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_qr_code_scanner.xml b/vector/src/main/res/layout/fragment_qr_code_scanner.xml index c17c0d90da..fbde73c709 100644 --- a/vector/src/main/res/layout/fragment_qr_code_scanner.xml +++ b/vector/src/main/res/layout/fragment_qr_code_scanner.xml @@ -1,6 +1,7 @@ @@ -21,7 +22,7 @@ +