mirror of
https://github.com/element-hq/element-android
synced 2024-11-24 02:15:35 +03:00
Merge branch 'develop' into feature/fga/message_bubbles
This commit is contained in:
commit
1bf2523437
163 changed files with 5336 additions and 788 deletions
2
.github/workflows/integration_tests.yml
vendored
2
.github/workflows/integration_tests.yml
vendored
|
@ -180,6 +180,7 @@ jobs:
|
|||
body="$(cat ./matrix-sdk-android/build/outputs/androidTest-results/connected/*.xml | grep "<testsuite" | sed "s@.*tests=\(.*\)time=.*@\1@")"
|
||||
echo "::set-output name=permalink::passed=$body"
|
||||
- name: Find Comment
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: peter-evans/find-comment@v1
|
||||
id: fc
|
||||
with:
|
||||
|
@ -187,6 +188,7 @@ jobs:
|
|||
comment-author: 'github-actions[bot]'
|
||||
body-includes: Integration Tests Results
|
||||
- name: Publish results to PR
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: peter-evans/create-or-update-comment@v1
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
|
|
6
.github/workflows/quality.yml
vendored
6
.github/workflows/quality.yml
vendored
|
@ -54,7 +54,7 @@ jobs:
|
|||
echo "::set-output name=body::$body"
|
||||
fi
|
||||
- name: Find Comment
|
||||
if: always()
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
uses: peter-evans/find-comment@v1
|
||||
id: fc
|
||||
with:
|
||||
|
@ -62,7 +62,7 @@ jobs:
|
|||
comment-author: 'github-actions[bot]'
|
||||
body-includes: Ktlint Results
|
||||
- name: Add comment if needed
|
||||
if: always() && steps.ktlint-results.outputs.add_comment == 'true'
|
||||
if: always() && github.event_name == 'pull_request' && steps.ktlint-results.outputs.add_comment == 'true'
|
||||
uses: peter-evans/create-or-update-comment@v1
|
||||
with:
|
||||
comment-id: ${{ steps.fc.outputs.comment-id }}
|
||||
|
@ -73,7 +73,7 @@ jobs:
|
|||
${{ steps.ktlint-results.outputs.body }}
|
||||
edit-mode: replace
|
||||
- name: Delete comment if needed
|
||||
if: always() && steps.fc.outputs.comment-id != '' && steps.ktlint-results.outputs.add_comment == 'false'
|
||||
if: always() && github.event_name == 'pull_request' && steps.fc.outputs.comment-id != '' && steps.ktlint-results.outputs.add_comment == 'false'
|
||||
uses: actions/github-script@v3
|
||||
with:
|
||||
script: |
|
||||
|
|
17
build.gradle
17
build.gradle
|
@ -36,6 +36,12 @@ allprojects {
|
|||
apply plugin: "org.jlleitschuh.gradle.ktlint"
|
||||
|
||||
repositories {
|
||||
mavenCentral {
|
||||
content {
|
||||
groups.mavenCentral.regex.each { includeGroupByRegex it }
|
||||
groups.mavenCentral.group.each { includeGroup it }
|
||||
}
|
||||
}
|
||||
maven {
|
||||
url 'https://jitpack.io'
|
||||
content {
|
||||
|
@ -59,12 +65,6 @@ allprojects {
|
|||
groups.google.group.each { includeGroup it }
|
||||
}
|
||||
}
|
||||
mavenCentral {
|
||||
content {
|
||||
groups.mavenCentral.regex.each { includeGroupByRegex it }
|
||||
groups.mavenCentral.group.each { includeGroup it }
|
||||
}
|
||||
}
|
||||
//noinspection JcenterRepositoryObsolete
|
||||
jcenter {
|
||||
content {
|
||||
|
@ -144,6 +144,11 @@ project(":diff-match-patch") {
|
|||
}
|
||||
}
|
||||
|
||||
// Global configurations across all modules
|
||||
ext {
|
||||
isThreadingEnabled = true
|
||||
}
|
||||
|
||||
//project(":matrix-sdk-android") {
|
||||
// sonarqube {
|
||||
// properties {
|
||||
|
|
1
changelog.d/4746.feature
Normal file
1
changelog.d/4746.feature
Normal file
|
@ -0,0 +1 @@
|
|||
Initial implementation of thread messages
|
1
changelog.d/5118.misc
Normal file
1
changelog.d/5118.misc
Normal file
|
@ -0,0 +1 @@
|
|||
Fix CI/CD errors after merges for quality and integration tests
|
|
@ -7,7 +7,7 @@ ext.versions = [
|
|||
'targetCompat' : JavaVersion.VERSION_11,
|
||||
]
|
||||
|
||||
def gradle = "7.0.4"
|
||||
def gradle = "7.1.0"
|
||||
// Ref: https://kotlinlang.org/releases.html
|
||||
def kotlin = "1.5.31"
|
||||
def kotlinCoroutines = "1.5.2"
|
||||
|
@ -37,7 +37,6 @@ ext.libs = [
|
|||
'gradlePlugin' : "com.android.tools.build:gradle:$gradle",
|
||||
'kotlinPlugin' : "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin",
|
||||
'hiltPlugin' : "com.google.dagger:hilt-android-gradle-plugin:$dagger"
|
||||
|
||||
],
|
||||
jetbrains : [
|
||||
'coroutinesCore' : "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutines",
|
||||
|
|
|
@ -175,6 +175,7 @@ ext.groups = [
|
|||
'org.sonatype.oss',
|
||||
'org.testng',
|
||||
'org.threeten',
|
||||
'org.webjars',
|
||||
'ru.noties',
|
||||
'xerces',
|
||||
'xml-apis',
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<translate
|
||||
android:fromXDelta="-100%p" android:toXDelta="0"
|
||||
android:duration="@android:integer/config_mediumAnimTime"/>
|
||||
</set>
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<translate
|
||||
android:startOffset="250"
|
||||
android:fromXDelta="100%p" android:toXDelta="0"
|
||||
android:duration="@android:integer/config_mediumAnimTime"/>
|
||||
</set>
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<translate
|
||||
android:startOffset="250"
|
||||
android:fromXDelta="0" android:toXDelta="-100%p"
|
||||
android:duration="@android:integer/config_mediumAnimTime"/>
|
||||
</set>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<set xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<translate
|
||||
android:fromXDelta="0" android:toXDelta="100%p"
|
||||
android:duration="@android:integer/config_mediumAnimTime"/>
|
||||
</set>
|
4
library/ui-styles/src/main/res/values-v23/dimens.xml
Normal file
4
library/ui-styles/src/main/res/values-v23/dimens.xml
Normal file
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<dimen name="menu_item_ripple_size">28dp</dimen>
|
||||
</resources>
|
|
@ -46,6 +46,10 @@
|
|||
<dimen name="preview_url_view_corner_radius">8dp</dimen>
|
||||
<dimen name="preview_url_view_image_max_height">160dp</dimen>
|
||||
|
||||
<dimen name="menu_item_icon_size">24dp</dimen>
|
||||
<dimen name="menu_item_size">48dp</dimen>
|
||||
<dimen name="menu_item_ripple_size">48dp</dimen>
|
||||
|
||||
<!-- Composer -->
|
||||
<dimen name="composer_min_height">56dp</dimen>
|
||||
<dimen name="composer_attachment_size">52dp</dimen>
|
||||
|
|
|
@ -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<Optional<RoomSummary>> {
|
||||
|
@ -98,6 +100,20 @@ class FlowRoom(private val room: Room) {
|
|||
fun liveNotificationState(): Flow<RoomNotificationState> {
|
||||
return room.getLiveRoomNotificationState().asFlow()
|
||||
}
|
||||
|
||||
fun liveThreadList(): Flow<List<ThreadRootEvent>> {
|
||||
return room.getAllThreadsLive().asFlow()
|
||||
.startWith(room.coroutineDispatchers.io) {
|
||||
room.getAllThreads()
|
||||
}
|
||||
}
|
||||
|
||||
fun liveLocalUnreadThreadList(): Flow<List<ThreadRootEvent>> {
|
||||
return room.getMarkedThreadNotificationsLive().asFlow()
|
||||
.startWith(room.coroutineDispatchers.io) {
|
||||
room.getMarkedThreadNotifications()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Room.flow(): FlowRoom {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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<TimelineEvent> {
|
||||
private fun sendTextMessagesBatched(timeline: Timeline, room: Room, message: String, count: Int, timeout: Long, rootThreadEventId: String? = null): List<TimelineEvent> {
|
||||
val sentEvents = ArrayList<TimelineEvent>(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<TimelineEvent> {
|
||||
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 *****************************************************************************
|
||||
|
||||
/**
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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("<blockquote>") ?: 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<EncryptedEventContent>()?.relatesTo
|
||||
} else {
|
||||
content.toModel<MessageContent>()?.relatesTo
|
||||
content.toModel<MessageContent>()?.relatesTo ?: run {
|
||||
// Special case to handle stickers, while there is only a local msgtype for stickers
|
||||
if (getClearType() == EventType.STICKER) {
|
||||
getClearContent().toModel<MessageStickerContent>()?.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<RoomMemberContent>()?.membership == Membership.INVITE
|
||||
|
||||
fun Event.getPollContent(): MessagePollContent? {
|
||||
return content.toModel<MessagePollContent>()
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<Optional<EventAnnotationsSummary>>
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
|
|
@ -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<String>? = null
|
||||
)
|
||||
|
||||
fun ReplyToContent.shouldRenderInThread(): Boolean = renderIn?.contains("m.thread") == true
|
||||
|
|
|
@ -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<String>): Cancelable
|
||||
roomIds: Set<String>,
|
||||
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<ContentAttachmentData>,
|
||||
compressBeforeSending: Boolean,
|
||||
roomIds: Set<String>): Cancelable
|
||||
roomIds: Set<String>,
|
||||
rootThreadEventId: String? = null): Cancelable
|
||||
|
||||
/**
|
||||
* Send a poll to the room.
|
||||
|
|
|
@ -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<List<TimelineEvent>>
|
||||
|
||||
/**
|
||||
* Returns a list of all the thread root TimelineEvents that exists at the room level
|
||||
*/
|
||||
fun getAllThreads(): List<TimelineEvent>
|
||||
|
||||
/**
|
||||
* Returns a [LiveData] list of all the marked unread threads that exists at the room level
|
||||
*/
|
||||
fun getMarkedThreadNotificationsLive(): LiveData<List<TimelineEvent>>
|
||||
|
||||
/**
|
||||
* Returns a list of all the marked unread threads that exists at the room level
|
||||
*/
|
||||
fun getMarkedThreadNotifications(): List<TimelineEvent>
|
||||
|
||||
/**
|
||||
* Returns whether or not the current user is participating in the thread
|
||||
* @param rootThreadEventId the eventId of the current thread
|
||||
*/
|
||||
fun isUserParticipatingInThread(rootThreadEventId: String): Boolean
|
||||
|
||||
/**
|
||||
* Enhance the provided root thread TimelineEvent [List] by adding the latest
|
||||
* message edition for that thread
|
||||
* @return the enhanced [List] with edited updates
|
||||
*/
|
||||
fun mapEventsWithEdition(threads: List<TimelineEvent>): List<TimelineEvent>
|
||||
|
||||
/**
|
||||
* Marks the current thread as read in local DB.
|
||||
* note: read receipts within threads are not yet supported with the API
|
||||
* @param rootThreadEventId the root eventId of the current thread
|
||||
*/
|
||||
suspend fun markThreadAsRead(rootThreadEventId: String)
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
)
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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 = 22L
|
||||
const val SESSION_STORE_SCHEMA_VERSION = 23L
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -91,6 +92,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
|
|||
if (oldVersion <= 19) migrateTo20(realm)
|
||||
if (oldVersion <= 20) migrateTo21(realm)
|
||||
if (oldVersion <= 21) migrateTo22(realm)
|
||||
if (oldVersion <= 22) migrateTo23(realm)
|
||||
}
|
||||
|
||||
private fun migrateTo1(realm: DynamicRealm) {
|
||||
|
@ -449,12 +451,25 @@ 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)
|
||||
}
|
||||
|
||||
private fun migrateTo23(realm: DynamicRealm) {
|
||||
Timber.d("Step 22 -> 23")
|
||||
realm.schema.get("PreviewUrlCacheEntity")
|
||||
?.addField(PreviewUrlCacheEntityFields.IMAGE_WIDTH, Int::class.java)
|
||||
?.setNullable(PreviewUrlCacheEntityFields.IMAGE_WIDTH, true)
|
||||
?.addField(PreviewUrlCacheEntityFields.IMAGE_HEIGHT, Int::class.java)
|
||||
?.setNullable(PreviewUrlCacheEntityFields.IMAGE_HEIGHT, true)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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<String, RoomMemberContent?>) {
|
||||
roomMemberContentsByUser: Map<String, RoomMemberContent?>? = 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<ReadReceiptsSummaryEntity>(eventEntity.eventId).apply {
|
||||
|
|
|
@ -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<Int, TimelineEventEntity>?
|
||||
|
||||
/**
|
||||
* Finds the root thread event and update it with the latest message summary along with the number
|
||||
* of threads included. If there is no root thread event no action is done
|
||||
*/
|
||||
internal fun Map<String, EventEntity>.updateThreadSummaryIfNeeded(
|
||||
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> =
|
||||
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<TimelineEvent>.mapEventsWithEdition(realm: Realm, roomId: String): List<TimelineEvent> =
|
||||
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> =
|
||||
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<TimelineEventEntity>()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<EventEntity> {
|
||||
return realm.where<EventEntity>()
|
||||
.equalTo(EventEntityFields.ROOM_ID, roomId)
|
||||
}
|
||||
|
||||
internal fun EventEntity.Companion.where(realm: Realm, eventIds: List<String>): RealmQuery<EventEntity> {
|
||||
return realm.where<EventEntity>()
|
||||
.`in`(EventEntityFields.EVENT_ID, eventIds.toTypedArray())
|
||||
|
@ -85,3 +90,8 @@ internal fun RealmList<EventEntity>.find(eventId: String): EventEntity? {
|
|||
internal fun RealmList<EventEntity>.fastContains(eventId: String): Boolean {
|
||||
return this.find(eventId) != null
|
||||
}
|
||||
|
||||
internal fun EventEntity.Companion.whereRootThreadEventId(realm: Realm, rootThreadEventId: String): RealmQuery<EventEntity> {
|
||||
return realm.where<EventEntity>()
|
||||
.equalTo(EventEntityFields.ROOT_THREAD_EVENT_ID, rootThreadEventId)
|
||||
}
|
||||
|
|
|
@ -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<TimelineEventEntity>.filterEvents(filters: TimelineEvent
|
|||
if (filters.filterRedacted) {
|
||||
not().like(TimelineEventEntityFields.ROOT.UNSIGNED_DATA, TimelineEventFilter.Unsigned.REDACTED)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
|
|
|
@ -48,6 +48,16 @@ data class RoomEventFilter(
|
|||
* a wildcard to match any sequence of characters.
|
||||
*/
|
||||
@Json(name = "types") val types: List<String>? = 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<String>? = null,
|
||||
/**
|
||||
* A list of senders of relations which must exist pointing to the event being filtered.
|
||||
* If this list is absent then no filtering is done on relation types.
|
||||
*/
|
||||
@Json(name = "relation_senders") val relationSenders: List<String>? = null,
|
||||
/**
|
||||
* A list of room IDs to include. If this list is absent then all rooms are included.
|
||||
*/
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<EditionOfEvent>?) {
|
||||
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,
|
||||
|
|
|
@ -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
|
||||
|
||||
/**
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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<FetchThreadTimelineTask.Params, Boolean> {
|
||||
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<Event>, roomId: String): Boolean {
|
||||
var eventsSkipped = 0
|
||||
monarchy
|
||||
.awaitTransaction { realm ->
|
||||
val chunk = ChunkEntity.findLastForwardChunkOfRoom(realm, roomId)
|
||||
|
||||
val optimizedThreadSummaryMap = hashMapOf<String, EventEntity>()
|
||||
val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>()
|
||||
|
||||
for (event in threadList.reversed()) {
|
||||
if (event.eventId == null || event.senderId == null || event.type == null) {
|
||||
eventsSkipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if (EventEntity.where(realm, event.eventId).findFirst() != null) {
|
||||
// Skip if event already exists
|
||||
eventsSkipped++
|
||||
continue
|
||||
}
|
||||
if (event.isEncrypted()) {
|
||||
// Decrypt events that will be stored
|
||||
decryptIfNeeded(event, roomId)
|
||||
}
|
||||
|
||||
handleReaction(realm, event, roomId)
|
||||
|
||||
val ageLocalTs = event.unsignedData?.age?.let { System.currentTimeMillis() - it }
|
||||
val eventEntity = event.toEntity(roomId, SendState.SYNCED, ageLocalTs).copyToRealmOrIgnore(realm, EventInsertType.INCREMENTAL_SYNC)
|
||||
|
||||
// Sender info
|
||||
roomMemberContentsByUser.getOrPut(event.senderId) {
|
||||
// If we don't have any new state on this user, get it from db
|
||||
val rootStateEvent = CurrentStateEventEntity.getOrNull(realm, roomId, event.senderId, EventType.STATE_ROOM_MEMBER)?.root
|
||||
rootStateEvent?.asDomain()?.getFixedRoomMemberContent()
|
||||
}
|
||||
|
||||
chunk?.addTimelineEvent(roomId, eventEntity, PaginationDirection.FORWARDS, roomMemberContentsByUser)
|
||||
eventEntity.rootThreadEventId?.let {
|
||||
// This is a thread event
|
||||
optimizedThreadSummaryMap[it] = eventEntity
|
||||
} ?: run {
|
||||
// This is a normal event or a root thread one
|
||||
optimizedThreadSummaryMap[eventEntity.eventId] = eventEntity
|
||||
}
|
||||
}
|
||||
|
||||
optimizedThreadSummaryMap.updateThreadSummaryIfNeeded(
|
||||
roomId = roomId,
|
||||
realm = realm,
|
||||
currentUserId = userId,
|
||||
shouldUpdateNotifications = false
|
||||
)
|
||||
}
|
||||
Timber.i("----> size: ${threadList.size} | skipped: $eventsSkipped | threads: ${threadList.map { it.eventId }}")
|
||||
|
||||
return eventsSkipped == threadList.size
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<ContentAttachmentData>,
|
||||
compressBeforeSending: Boolean,
|
||||
roomIds: Set<String>): Cancelable {
|
||||
roomIds: Set<String>,
|
||||
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<String>): Cancelable {
|
||||
roomIds: Set<String>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<MessageStickerContent>())?.relatesTo?.type == RelationType.IO_THREAD
|
||||
val rootThreadEventId = (content.toModel<MessageStickerContent>())?.relatesTo?.eventId
|
||||
if (isThread && rootThreadEventId != null) {
|
||||
val newRelationalDefaultContent = (content.toModel<MessageStickerContent>())?.relatesTo?.copy(
|
||||
inReplyTo = ReplyToContent(eventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId))
|
||||
)
|
||||
newContent = (content.toModel<MessageStickerContent>())?.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(
|
|||
// </mx-reply>
|
||||
// No whitespace because currently breaks temporary formatted text to Span
|
||||
const val REPLY_PATTERN = """<mx-reply><blockquote><a href="%s">In reply to</a> <a href="%s">%s</a><br />%s</blockquote></mx-reply>%s"""
|
||||
const val QUOTE_PATTERN = """<blockquote><p>%s</p></blockquote><p>%s</p>"""
|
||||
|
||||
// This is used to replace inner mx-reply tags
|
||||
val MX_REPLY_REGEX = "<mx-reply>.*</mx-reply>".toRegex()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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<List<TimelineEvent>> {
|
||||
return monarchy.findAllMappedWithChanges(
|
||||
{ TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) },
|
||||
{ timelineEventMapper.map(it) }
|
||||
)
|
||||
}
|
||||
|
||||
override fun getMarkedThreadNotifications(): List<TimelineEvent> {
|
||||
return monarchy.fetchAllMappedSync(
|
||||
{ TimelineEventEntity.findAllLocalThreadNotificationsForRoomId(it, roomId = roomId) },
|
||||
{ timelineEventMapper.map(it) }
|
||||
)
|
||||
}
|
||||
|
||||
override fun getAllThreadsLive(): LiveData<List<TimelineEvent>> {
|
||||
return monarchy.findAllMappedWithChanges(
|
||||
{ TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
|
||||
{ timelineEventMapper.map(it) }
|
||||
)
|
||||
}
|
||||
|
||||
override fun getAllThreads(): List<TimelineEvent> {
|
||||
return monarchy.fetchAllMappedSync(
|
||||
{ TimelineEventEntity.findAllThreadsForRoomId(it, roomId = roomId) },
|
||||
{ timelineEventMapper.map(it) }
|
||||
)
|
||||
}
|
||||
|
||||
override fun isUserParticipatingInThread(rootThreadEventId: String): Boolean {
|
||||
return Realm.getInstance(monarchy.realmConfiguration).use {
|
||||
TimelineEventEntity.isUserParticipatingInThread(
|
||||
realm = it,
|
||||
roomId = roomId,
|
||||
rootThreadEventId = rootThreadEventId,
|
||||
senderId = userId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun mapEventsWithEdition(threads: List<TimelineEvent>): List<TimelineEvent> {
|
||||
return Realm.getInstance(monarchy.realmConfiguration).use {
|
||||
threads.mapEventsWithEdition(it, roomId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun markThreadAsRead(rootThreadEventId: String) {
|
||||
monarchy.awaitTransaction {
|
||||
EventEntity.where(
|
||||
realm = it,
|
||||
eventId = rootThreadEventId).findFirst()?.threadNotificationState = ThreadNotificationState.NO_NEW_MESSAGE
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Unit>(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,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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<String>) -> Unit
|
||||
|
@ -198,12 +209,20 @@ internal class LoadTimelineStrategy(
|
|||
}
|
||||
|
||||
private fun getChunkEntity(realm: Realm): RealmResults<ChunkEntity> {
|
||||
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
|
||||
)
|
||||
|
|
|
@ -29,12 +29,14 @@ 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
|
||||
import org.matrix.android.sdk.internal.database.model.ChunkEntityFields
|
||||
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
|
||||
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
|
||||
import org.matrix.android.sdk.internal.database.query.where
|
||||
import org.matrix.android.sdk.internal.session.sync.handler.room.ThreadsAwarenessHandler
|
||||
import timber.log.Timber
|
||||
import java.util.Collections
|
||||
|
@ -55,6 +57,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 +95,7 @@ internal class TimelineChunk(private val chunkEntity: ChunkEntity,
|
|||
handleDatabaseChangeSet(frozenResults, changeSet)
|
||||
}
|
||||
|
||||
private var timelineEventEntities: RealmResults<TimelineEventEntity> = chunkEntity.sortedTimelineEvents()
|
||||
private var timelineEventEntities: RealmResults<TimelineEventEntity> = chunkEntity.sortedTimelineEvents(timelineSettings.rootThreadEventId)
|
||||
private val builtEvents: MutableList<TimelineEvent> = Collections.synchronizedList(ArrayList())
|
||||
private val builtEventsIndexes: MutableMap<String, Int> = Collections.synchronizedMap(HashMap<String, Int>())
|
||||
|
||||
|
@ -137,13 +140,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 +195,15 @@ 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<TimelineEventEntity>): 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 {
|
||||
|
@ -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<TimelineEventEntity>.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<TimelineEventEntity> {
|
||||
return timelineEvents.sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.DESCENDING)
|
||||
private fun ChunkEntity.sortedTimelineEvents(rootThreadEventId: String?): RealmResults<TimelineEventEntity> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}")
|
||||
|
|
|
@ -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<StreamEventsManager>) {
|
||||
@SessionDatabase private val monarchy: Monarchy,
|
||||
@UserId private val userId: String,
|
||||
private val lightweightSettingsStorage: LightweightSettingsStorage,
|
||||
private val liveEventManager: Lazy<StreamEventsManager>) {
|
||||
|
||||
enum class Result {
|
||||
SHOULD_FETCH_MORE,
|
||||
|
@ -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<RoomMemberContent>()
|
||||
}
|
||||
}
|
||||
val optimizedThreadSummaryMap = hashMapOf<String, EventEntity>()
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<SearchTask.Params, SearchResult> {
|
|||
|
||||
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<SearchResponseItem>?): List<TimelineEventEntity>? {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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<StreamEventsManager>) {
|
||||
|
||||
|
@ -363,10 +367,12 @@ internal class RoomSyncHandler @Inject constructor(private val readReceiptHandle
|
|||
val eventIds = ArrayList<String>(eventList.size)
|
||||
val roomMemberContentsByUser = HashMap<String, RoomMemberContent?>()
|
||||
|
||||
val optimizedThreadSummaryMap = hashMapOf<String, EventEntity>()
|
||||
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)
|
||||
|
|
|
@ -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<String>()
|
||||
|
||||
/**
|
||||
* 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<String, String>().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<String, Any>,
|
||||
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, Any>): 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<EventEntity>? {
|
||||
val threadList = realm.where<EventEntity>()
|
||||
.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<MessageRelationContent>()?.relatesTo?.type == RelationType.THREAD
|
||||
event.content.toModel<MessageRelationContent>()?.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<MessageRelationContent>()?.relatesTo?.eventId
|
||||
|
||||
private fun getPreviousEventOrRoot(event: Event): String? =
|
||||
event.content.toModel<MessageRelationContent>()?.relatesTo?.inReplyTo?.eventId
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun getValueFromPayload(payload: JsonDict?, key: String): String? {
|
||||
val content = payload?.get("content") as? JsonDict
|
||||
|
|
|
@ -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) {
|
||||
|
|
0
tools/check/forbidden_strings_in_code.txt
Normal file → Executable file
0
tools/check/forbidden_strings_in_code.txt
Normal file → Executable file
2
tools/check/forbidden_strings_in_layout.txt
Normal file → Executable file
2
tools/check/forbidden_strings_in_layout.txt
Normal file → Executable file
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
@ -443,7 +445,7 @@ dependencies {
|
|||
|
||||
implementation libs.github.glide
|
||||
kapt libs.github.glideCompiler
|
||||
implementation 'com.github.yalantis:ucrop:2.2.7'
|
||||
implementation 'com.github.yalantis:ucrop:2.2.8'
|
||||
|
||||
// Badge for compatibility
|
||||
implementation 'me.leolin:ShortcutBadger:1.1.22@aar'
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
<issue id="MissingTranslation" severity="ignore" />
|
||||
<issue id="TypographyEllipsis" severity="error" />
|
||||
<issue id="ImpliedQuantity" severity="warning" />
|
||||
<issue id="MissingQuantity" severity="warning" />
|
||||
<issue id="UnusedQuantity" severity="error" />
|
||||
<issue id="IconXmlAndPng" severity="error" />
|
||||
<issue id="IconDipSize" severity="error" />
|
||||
|
|
|
@ -175,6 +175,8 @@
|
|||
<activity android:name=".features.roomdirectory.RoomDirectoryActivity" />
|
||||
<activity android:name=".features.roomdirectory.roompreview.RoomPreviewActivity" />
|
||||
<activity android:name=".features.home.room.filtered.FilteredRoomsActivity" />
|
||||
<activity android:name=".features.home.room.threads.ThreadsActivity" />
|
||||
|
||||
<activity
|
||||
android:name=".features.home.room.detail.RoomDetailActivity"
|
||||
android:parentActivityName=".features.home.HomeActivity">
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
@ -537,8 +537,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
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -48,4 +48,8 @@ class UserPreferencesProvider @Inject constructor(private val vectorPreferences:
|
|||
fun shouldShowAvatarDisplayNameChanges(): Boolean {
|
||||
return vectorPreferences.showAvatarDisplayNameChangeMessages()
|
||||
}
|
||||
|
||||
fun areThreadMessagesEnabled(): Boolean {
|
||||
return vectorPreferences.areThreadMessagesEnabled()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Command>(context), AutocompleteClickListener<Command> {
|
||||
|
||||
@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
|
||||
|
|
|
@ -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<ActivityCallBinding>(), 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
|
||||
}
|
||||
|
|
|
@ -28,41 +28,42 @@ enum class Command(val command: String,
|
|||
val aliases: Array<CharSequence>?,
|
||||
val parameters: String,
|
||||
@StringRes val description: Int,
|
||||
val isDevCommand: Boolean) {
|
||||
EMOTE("/me", null, "<message>", R.string.command_description_emote, false),
|
||||
BAN_USER("/ban", null, "<user-id> [reason]", R.string.command_description_ban_user, false),
|
||||
UNBAN_USER("/unban", null, "<user-id> [reason]", R.string.command_description_unban_user, false),
|
||||
IGNORE_USER("/ignore", null, "<user-id> [reason]", R.string.command_description_ignore_user, false),
|
||||
UNIGNORE_USER("/unignore", null, "<user-id>", R.string.command_description_unignore_user, false),
|
||||
SET_USER_POWER_LEVEL("/op", null, "<user-id> [<power-level>]", R.string.command_description_op_user, false),
|
||||
RESET_USER_POWER_LEVEL("/deop", null, "<user-id>", R.string.command_description_deop_user, false),
|
||||
ROOM_NAME("/roomname", null, "<name>", R.string.command_description_room_name, false),
|
||||
INVITE("/invite", null, "<user-id> [reason]", R.string.command_description_invite_user, false),
|
||||
JOIN_ROOM("/join", arrayOf("/j", "/goto"), "<room-address> [reason]", R.string.command_description_join_room, false),
|
||||
PART("/part", null, "[<room-address>]", R.string.command_description_part_room, false),
|
||||
TOPIC("/topic", null, "<topic>", R.string.command_description_topic, false),
|
||||
REMOVE_USER("/remove", arrayOf("/kick"), "<user-id> [reason]", R.string.command_description_remove_user, false),
|
||||
CHANGE_DISPLAY_NAME("/nick", null, "<display-name>", R.string.command_description_nick, false),
|
||||
CHANGE_DISPLAY_NAME_FOR_ROOM("/myroomnick", arrayOf("/roomnick"), "<display-name>", R.string.command_description_nick_for_room, false),
|
||||
ROOM_AVATAR("/roomavatar", null, "<mxc_url>", R.string.command_description_room_avatar, true /* Since user has to know the mxc url */),
|
||||
CHANGE_AVATAR_FOR_ROOM("/myroomavatar", null, "<mxc_url>", R.string.command_description_avatar_for_room, true /* Since user has to know the mxc url */),
|
||||
MARKDOWN("/markdown", null, "<on|off>", R.string.command_description_markdown, false),
|
||||
RAINBOW("/rainbow", null, "<message>", R.string.command_description_rainbow, false),
|
||||
RAINBOW_EMOTE("/rainbowme", null, "<message>", 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, "<message>", R.string.command_description_spoiler, false),
|
||||
SHRUG("/shrug", null, "<message>", R.string.command_description_shrug, false),
|
||||
LENNY("/lenny", null, "<message>", R.string.command_description_lenny, false),
|
||||
PLAIN("/plain", null, "<message>", R.string.command_description_plain, false),
|
||||
WHOIS("/whois", null, "<user-id>", R.string.command_description_whois, false),
|
||||
DISCARD_SESSION("/discardsession", null, "", R.string.command_description_discard_session, false),
|
||||
CONFETTI("/confetti", null, "<message>", R.string.command_confetti, false),
|
||||
SNOWFALL("/snowfall", null, "<message>", R.string.command_snow, false),
|
||||
CREATE_SPACE("/createspace", null, "<name> <invitee>*", 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, "<roomId?>", 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, "<message>", R.string.command_description_emote, false, true),
|
||||
BAN_USER("/ban", null, "<user-id> [reason]", R.string.command_description_ban_user, false, false),
|
||||
UNBAN_USER("/unban", null, "<user-id> [reason]", R.string.command_description_unban_user, false, false),
|
||||
IGNORE_USER("/ignore", null, "<user-id> [reason]", R.string.command_description_ignore_user, false, true),
|
||||
UNIGNORE_USER("/unignore", null, "<user-id>", R.string.command_description_unignore_user, false, true),
|
||||
SET_USER_POWER_LEVEL("/op", null, "<user-id> [<power-level>]", R.string.command_description_op_user, false, false),
|
||||
RESET_USER_POWER_LEVEL("/deop", null, "<user-id>", R.string.command_description_deop_user, false, false),
|
||||
ROOM_NAME("/roomname", null, "<name>", R.string.command_description_room_name, false, false),
|
||||
INVITE("/invite", null, "<user-id> [reason]", R.string.command_description_invite_user, false, false),
|
||||
JOIN_ROOM("/join", arrayOf("/j", "/goto"), "<room-address> [reason]", R.string.command_description_join_room, false, false),
|
||||
PART("/part", null, "[<room-address>]", R.string.command_description_part_room, false, false),
|
||||
TOPIC("/topic", null, "<topic>", R.string.command_description_topic, false, false),
|
||||
REMOVE_USER("/remove", arrayOf("/kick"), "<user-id> [reason]", R.string.command_description_remove_user, false, false),
|
||||
CHANGE_DISPLAY_NAME("/nick", null, "<display-name>", R.string.command_description_nick, false, false),
|
||||
CHANGE_DISPLAY_NAME_FOR_ROOM("/myroomnick", arrayOf("/roomnick"), "<display-name>", R.string.command_description_nick_for_room, false, false),
|
||||
ROOM_AVATAR("/roomavatar", null, "<mxc_url>", R.string.command_description_room_avatar, true /* User has to know the mxc url */, false),
|
||||
CHANGE_AVATAR_FOR_ROOM("/myroomavatar", null, "<mxc_url>", R.string.command_description_avatar_for_room, true /* User has to know the mxc url */, false),
|
||||
MARKDOWN("/markdown", null, "<on|off>", R.string.command_description_markdown, false, false),
|
||||
RAINBOW("/rainbow", null, "<message>", R.string.command_description_rainbow, false, true),
|
||||
RAINBOW_EMOTE("/rainbowme", null, "<message>", 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, "<message>", R.string.command_description_spoiler, false, true),
|
||||
SHRUG("/shrug", null, "<message>", R.string.command_description_shrug, false, true),
|
||||
LENNY("/lenny", null, "<message>", R.string.command_description_lenny, false, true),
|
||||
PLAIN("/plain", null, "<message>", R.string.command_description_plain, false, true),
|
||||
WHOIS("/whois", null, "<user-id>", R.string.command_description_whois, false, true),
|
||||
DISCARD_SESSION("/discardsession", null, "", R.string.command_description_discard_session, false, false),
|
||||
CONFETTI("/confetti", null, "<message>", R.string.command_confetti, false, false),
|
||||
SNOWFALL("/snowfall", null, "<message>", R.string.command_snow, false, false),
|
||||
CREATE_SPACE("/createspace", null, "<name> <invitee>*", 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, "<roomId?>", 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())
|
||||
|
||||
|
|
|
@ -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<Command> 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>): String? {
|
||||
val partsSize = messageParts.sumOf { it.length }
|
||||
val gapsNumber = messageParts.size - 1
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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<RoomDetailArgs>(RoomDetailActivity.EXTRA_ROOM_DETAIL_ARGS)?.let {
|
||||
activity.intent?.extras?.getParcelable<TimelineArgs>(RoomDetailActivity.EXTRA_ROOM_DETAIL_ARGS)?.let {
|
||||
it.roomId != pr.roomId
|
||||
} ?: true
|
||||
} else true
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<Array<String>>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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<RoomDetailAction.TimelineEventTurnsInvisible>()
|
||||
private val visibleEventsSource = BehaviorDataSource<RoomDetailAction.TimelineEventTurnsVisible>()
|
||||
private var timelineEvents = MutableSharedFlow<List<TimelineEvent>>(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<Unit> = Uninitialized
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory : MavericksAssistedViewModelFactory<RoomDetailViewModel, RoomDetailViewState> {
|
||||
override fun create(initialState: RoomDetailViewState): RoomDetailViewModel
|
||||
interface Factory : MavericksAssistedViewModelFactory<TimelineViewModel, RoomDetailViewState> {
|
||||
override fun create(initialState: RoomDetailViewState): TimelineViewModel
|
||||
}
|
||||
|
||||
companion object : MavericksViewModelFactory<RoomDetailViewModel, RoomDetailViewState> by hiltMavericksViewModelFactory() {
|
||||
companion object : MavericksViewModelFactory<TimelineViewModel, RoomDetailViewState> 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()
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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})",
|
||||
"<span data-mx-spoiler>${slashCommandResult.message}</span>"
|
||||
)
|
||||
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 = "<span data-mx-spoiler>${slashCommandResult.message}</span>"
|
||||
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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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<SearchViewState>() {
|
||||
|
||||
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) }
|
||||
}
|
||||
|
|
|
@ -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<SearchResultItem.Holder>() {
|
|||
@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<SearchResultItem.Holder>() {
|
|||
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<SearchResultItem.Holder>() {
|
|||
val memberNameView by bind<TextView>(R.id.messageMemberNameView)
|
||||
val timeView by bind<TextView>(R.id.messageTimeView)
|
||||
val contentView by bind<TextView>(R.id.messageContentView)
|
||||
val threadSummaryConstraintLayout by bind<ConstraintLayout>(R.id.searchThreadSummaryConstraintLayout)
|
||||
val threadSummaryCounterTextView by bind<TextView>(R.id.messageThreadSummaryCounterTextView)
|
||||
val threadSummaryAvatarImageView by bind<ImageView>(R.id.messageThreadSummaryAvatarImageView)
|
||||
val threadSummaryInfoTextView by bind<TextView>(R.id.messageThreadSummaryInfoTextView)
|
||||
val fromThreadConstraintLayout by bind<ConstraintLayout>(R.id.searchFromThreadConstraintLayout)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<ReadReceiptData>)
|
||||
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<TimelineEvent>) {
|
||||
// Update is triggered on any DB change
|
||||
backgroundHandler.post {
|
||||
inSubmitList = true
|
||||
val diffCallback = TimelineEventDiffUtilCallback(currentSnapshot, newSnapshot)
|
||||
|
@ -371,10 +386,18 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
|
|||
if (modelCache[position] == null || modelCache[position]?.isCacheable(partialState) == false) {
|
||||
val prevEvent = currentSnapshot.prevOrNull(position)
|
||||
val prevDisplayableEvent = currentSnapshot.subList(0, position).lastOrNull {
|
||||
timelineEventVisibilityHelper.shouldShowEvent(it, partialState.highlightedEventId)
|
||||
timelineEventVisibilityHelper.shouldShowEvent(
|
||||
timelineEvent = it,
|
||||
highlightedEventId = partialState.highlightedEventId,
|
||||
isFromThreadTimeline = partialState.isFromThreadTimeline(),
|
||||
rootThreadEventId = partialState.rootThreadEventId)
|
||||
}
|
||||
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)
|
||||
}
|
||||
val timelineEventsGroup = timelineEventsGroups.getOrNull(event)
|
||||
val params = TimelineItemFactoryParams(
|
||||
|
@ -440,7 +463,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
|
||||
)
|
||||
|
@ -457,7 +485,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
|
||||
|
@ -479,7 +512,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) {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -49,10 +49,15 @@ data class MessageActionState(
|
|||
// For actions
|
||||
val actions: List<EventSharedAction> = emptyList(),
|
||||
val expendedReportContentMenu: Boolean = false,
|
||||
val actionPermissions: ActionPermissions = ActionPermissions()
|
||||
val actionPermissions: ActionPermissions = ActionPermissions(),
|
||||
val isFromThreadTimeline: Boolean = false
|
||||
) : MavericksState {
|
||||
|
||||
constructor(args: TimelineEventFragmentArgs) : this(roomId = args.roomId, eventId = args.eventId, informationData = args.informationData)
|
||||
constructor(args: TimelineEventFragmentArgs) : this(
|
||||
roomId = args.roomId,
|
||||
eventId = args.eventId,
|
||||
informationData = args.informationData,
|
||||
isFromThreadTimeline = args.isFromThreadTimeline)
|
||||
|
||||
fun senderName(): String = informationData.memberName?.toString() ?: ""
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue