Merge pull request #322 from vector-im/feature/clean

Improve reply feature
This commit is contained in:
Valere 2019-07-11 11:46:00 +02:00 committed by GitHub
commit 98306e223b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 297 additions and 232 deletions

View File

@ -16,8 +16,8 @@
package im.vector.matrix.android.api.session.room.model.relation

import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.util.Cancelable

/**
@ -77,8 +77,9 @@ interface RelationService {
* https://matrix.org/docs/spec/client_server/r0.4.0.html#id350
* @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
*/
fun replyToMessage(eventReplied: Event, replyText: String): Cancelable?
fun replyToMessage(eventReplied: TimelineEvent, replyText: String, autoMarkdown: Boolean = false): Cancelable?

fun getEventSummaryLive(eventId: String): LiveData<EventAnnotationsSummary>
}

View File

@ -18,7 +18,9 @@ package im.vector.matrix.android.api.session.room.timeline

import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.send.SendState

/**
@ -80,3 +82,9 @@ data class TimelineEvent(
return EventType.ENCRYPTED == root.type
}
}

/**
* Get last MessageContent, after a possible edition
*/
fun TimelineEvent.getLastMessageContent(): MessageContent? = annotations?.editSummary?.aggregatedContent?.toModel()
?: root.getClearContent().toModel()

View File

@ -73,6 +73,7 @@ import im.vector.matrix.android.internal.session.room.membership.RoomMembers
import im.vector.matrix.android.internal.session.sync.model.SyncResponse
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.task.toConfigurableTask
import im.vector.matrix.android.internal.util.JsonCanonicalizer
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
import im.vector.matrix.android.internal.util.fetchCopied
@ -197,7 +198,7 @@ internal class CryptoManager @Inject constructor(

override fun getDevicesList(callback: MatrixCallback<DevicesListResponse>) {
getDevicesTask
.configureWith(Unit)
.toConfigurableTask()
.dispatchTo(callback)
.executeBy(taskExecutor)
}
@ -1054,7 +1055,8 @@ internal class CryptoManager @Inject constructor(
}

override fun clearCryptoCache(callback: MatrixCallback<Unit>) {
clearCryptoDataTask.configureWith(Unit)
clearCryptoDataTask
.toConfigurableTask()
.dispatchTo(callback)
.executeBy(taskExecutor)
}

View File

@ -51,6 +51,7 @@ import im.vector.matrix.android.internal.crypto.store.db.model.KeysBackupDataEnt
import im.vector.matrix.android.internal.di.MoshiProvider
import im.vector.matrix.android.internal.extensions.foldToCallback
import im.vector.matrix.android.internal.session.SessionScope
import im.vector.matrix.android.internal.task.*
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.TaskThread
@ -876,7 +877,7 @@ internal class KeysBackup @Inject constructor(

override fun getCurrentVersion(callback: MatrixCallback<KeysVersionResult?>) {
getKeysBackupLastVersionTask
.configureWith(Unit)
.toConfigurableTask()
.dispatchTo(object : MatrixCallback<KeysVersionResult> {
override fun onSuccess(data: KeysVersionResult) {
callback.onSuccess(data)

View File

@ -19,9 +19,8 @@ package im.vector.matrix.android.internal.session.cache
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.cache.CacheService
import im.vector.matrix.android.internal.di.SessionDatabase
import im.vector.matrix.android.internal.session.SessionScope
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.task.toConfigurableTask
import javax.inject.Inject

internal class DefaultCacheService @Inject constructor(@SessionDatabase private val clearCacheTask: ClearCacheTask,
@ -29,7 +28,7 @@ internal class DefaultCacheService @Inject constructor(@SessionDatabase private

override fun clearCache(callback: MatrixCallback<Unit>) {
clearCacheTask
.configureWith(Unit)
.toConfigurableTask()
.dispatchTo(callback)
.executeBy(taskExecutor)
}

View File

@ -29,6 +29,7 @@ import im.vector.matrix.android.internal.database.model.PusherEntity
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.task.toConfigurableTask
import im.vector.matrix.android.internal.worker.WorkManagerUtil
import im.vector.matrix.android.internal.worker.WorkManagerUtil.matrixOneTimeWorkRequestBuilder
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
@ -49,7 +50,7 @@ internal class DefaultPusherService @Inject constructor(

override fun refreshPushers() {
getPusherTask
.configureWith(Unit)
.toConfigurableTask()
.executeBy(taskExecutor)
}


View File

@ -27,6 +27,7 @@ import im.vector.matrix.android.internal.session.room.directory.GetThirdPartyPro
import im.vector.matrix.android.internal.session.room.membership.joining.JoinRoomTask
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.task.toConfigurableTask
import javax.inject.Inject

internal class DefaultRoomDirectoryService @Inject constructor(private val getPublicRoomTask: GetPublicRoomTask,
@ -52,7 +53,7 @@ internal class DefaultRoomDirectoryService @Inject constructor(private val getPu

override fun getThirdPartyProtocol(callback: MatrixCallback<Map<String, ThirdPartyProtocol>>) {
getThirdPartyProtocolsTask
.configureWith(Unit)
.toConfigurableTask()
.dispatchTo(callback)
.executeBy(taskExecutor)
}

View File

@ -27,6 +27,7 @@ import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.relation.RelationService
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.database.RealmLiveData
import im.vector.matrix.android.internal.database.helper.addSendingEvent
@ -127,8 +128,8 @@ internal class DefaultRelationService @Inject constructor(private val context: C

}

override fun replyToMessage(eventReplied: Event, replyText: String): Cancelable? {
val event = eventFactory.createReplyTextEvent(roomId, eventReplied, replyText)?.also {
override fun replyToMessage(eventReplied: TimelineEvent, replyText: String, autoMarkdown: Boolean): Cancelable? {
val event = eventFactory.createReplyTextEvent(roomId, eventReplied, replyText, autoMarkdown)?.also {
saveLocalEcho(it)
} ?: return null


View File

@ -59,7 +59,7 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
}

override fun sendFormattedTextMessage(text: String, formattedText: String): Cancelable {
val event = localEchoEventFactory.createFormattedTextEvent(roomId, text, formattedText).also {
val event = localEchoEventFactory.createFormattedTextEvent(roomId, TextContent(text, formattedText)).also {
saveLocalEcho(it)
}


View File

@ -28,6 +28,8 @@ import im.vector.matrix.android.api.session.room.model.relation.ReactionContent
import im.vector.matrix.android.api.session.room.model.relation.ReactionInfo
import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent
import im.vector.matrix.android.api.session.room.model.relation.ReplyToContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
import im.vector.matrix.android.internal.database.helper.addSendingEvent
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.query.where
@ -52,68 +54,54 @@ import javax.inject.Inject
internal class LocalEchoEventFactory @Inject constructor(private val credentials: Credentials,
private val stringProvider: StringProvider,
private val roomSummaryUpdater: RoomSummaryUpdater) {
// TODO Inject
private val parser = Parser.builder().build()
// TODO Inject
private val renderer = HtmlRenderer.builder().build()

fun createTextEvent(roomId: String, msgType: String, text: String, autoMarkdown: Boolean): Event {
if (autoMarkdown && msgType == MessageType.MSGTYPE_TEXT) {
val parser = Parser.builder().build()
val document = parser.parse(text)
val renderer = HtmlRenderer.builder().build()
val htmlText = renderer.render(document)
if (isFormattedTextPertinent(text, htmlText)) { //FIXME
return createFormattedTextEvent(roomId, text, htmlText)
}
if (msgType == MessageType.MSGTYPE_TEXT) {
return createFormattedTextEvent(roomId, createTextContent(text, autoMarkdown))
}
val content = MessageTextContent(type = msgType, body = text)
return createEvent(roomId, content)
}

private fun createTextContent(text: String, autoMarkdown: Boolean): TextContent {
if (autoMarkdown) {
val document = parser.parse(text)
val htmlText = renderer.render(document)

if (isFormattedTextPertinent(text, htmlText)) {
return TextContent(text, htmlText)
}
}

return TextContent(text)
}

private fun isFormattedTextPertinent(text: String, htmlText: String?) =
text != htmlText && htmlText != "<p>${text.trim()}</p>\n"

fun createFormattedTextEvent(roomId: String, text: String, formattedText: String): Event {
val content = MessageTextContent(
type = MessageType.MSGTYPE_TEXT,
format = MessageType.FORMAT_MATRIX_HTML,
body = text,
formattedBody = formattedText
)
return createEvent(roomId, content)
fun createFormattedTextEvent(roomId: String, textContent: TextContent): Event {
return createEvent(roomId, textContent.toMessageTextContent())
}


fun createReplaceTextEvent(roomId: String,
targetEventId: String,
newBodyText: String,
newBodyAutoMarkdown: Boolean,
msgType: String,
compatibilityText: String): Event {

var newContent = MessageTextContent(
type = MessageType.MSGTYPE_TEXT,
body = newBodyText
)
if (newBodyAutoMarkdown) {
val parser = Parser.builder().build()
val document = parser.parse(newBodyText)
val renderer = HtmlRenderer.builder().build()
val htmlText = renderer.render(document)
if (isFormattedTextPertinent(newBodyText, htmlText)) {
newContent = MessageTextContent(
type = MessageType.MSGTYPE_TEXT,
format = MessageType.FORMAT_MATRIX_HTML,
body = newBodyText,
formattedBody = htmlText
)
}
}

val content = MessageTextContent(
type = msgType,
body = compatibilityText,
relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId),
newContent = newContent.toContent()
)
return createEvent(roomId, content)
return createEvent(roomId,
MessageTextContent(
type = msgType,
body = compatibilityText,
relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId),
newContent = createTextContent(newBodyText, newBodyAutoMarkdown)
.toMessageTextContent()
.toContent()
))
}

fun createMediaEvent(roomId: String, attachment: ContentAttachmentData): Event {
@ -202,7 +190,7 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials
type = MessageType.MSGTYPE_AUDIO,
body = attachment.name ?: "audio",
audioInfo = AudioInfo(
mimeType = attachment.mimeType ?: "audio/mpeg",
mimeType = attachment.mimeType.takeIf { it.isNotBlank() } ?: "audio/mpeg",
size = attachment.size
),
url = attachment.path
@ -215,7 +203,8 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials
type = MessageType.MSGTYPE_FILE,
body = attachment.name ?: "file",
info = FileInfo(
mimeType = attachment.mimeType ?: "application/octet-stream",
mimeType = attachment.mimeType.takeIf { it.isNotBlank() }
?: "application/octet-stream",
size = attachment.size
),
url = attachment.path
@ -244,82 +233,81 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials
return "$LOCAL_ID_PREFIX${UUID.randomUUID()}"
}

fun createReplyTextEvent(roomId: String, eventReplied: Event, replyText: String): Event? {
fun createReplyTextEvent(roomId: String, eventReplied: TimelineEvent, replyText: String, autoMarkdown: Boolean): Event? {
//Fallbacks and event representation
//TODO Add error/warning logs when any of this is null
val permalink = PermalinkFactory.createPermalink(eventReplied) ?: return null
val userId = eventReplied.senderId ?: return null
val permalink = PermalinkFactory.createPermalink(eventReplied.root) ?: return null
val userId = eventReplied.root.senderId ?: return null
val userLink = PermalinkFactory.createPermalink(userId) ?: return null
// <mx-reply>
// <blockquote>
// <a href="https://matrix.to/#/!somewhere:domain.com/$event:domain.com">In reply to</a>
// <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a>
// <br />
// <!-- This is where the related event's HTML would be. -->
// </blockquote>
// </mx-reply>
// This is where the reply goes.
val body = bodyForReply(eventReplied.getClearContent().toModel<MessageContent>())
val replyFallbackTemplateFormatted = """<mx-reply>
<blockquote>
<a href="%s">${stringProvider.getString(R.string.message_reply_to_prefix)}</a>
<a href="%s">%s</a>
<br />
%s
</blockquote>
</mx-reply>
%s""".trimIndent().format(permalink, userLink, userId, body.second ?: body.first, replyText)
//
// > <@alice:example.org> This is the original body
//
// This is where the reply goes
val lines = body.first.split("\n")
val plainTextBody = StringBuffer("><${userId}>")
lines.firstOrNull()?.also { plainTextBody.append(" $it") }
// <mx-reply>
// <blockquote>
// <a href="https://matrix.to/#/!somewhere:domain.com/$event:domain.com">In reply to</a>
// <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a>
// <br />
// <!-- This is where the related event's HTML would be. -->
// </blockquote>
// </mx-reply>
// This is where the reply goes.
val body = bodyForReply(eventReplied.getLastMessageContent())
val replyFormatted = REPLY_PATTERN.format(
permalink,
stringProvider.getString(R.string.message_reply_to_prefix),
userLink,
userId,
body.takeFormatted(),
createTextContent(replyText, autoMarkdown).takeFormatted()
)
//
// > <@alice:example.org> This is the original body
//
val lines = body.text.split("\n")
val replyFallback = StringBuffer("><$userId>")
lines.forEachIndexed { index, s ->
if (index > 0) {
plainTextBody.append("\n>$s")
if (index == 0) {
replyFallback.append(" $s")
} else {
replyFallback.append("\n>$s")
}
}
plainTextBody.append("\n\n").append(replyText)
replyFallback.append("\n\n").append(replyText)

val eventId = eventReplied.eventId ?: return null
val eventId = eventReplied.root.eventId ?: return null
val content = MessageTextContent(
type = MessageType.MSGTYPE_TEXT,
format = MessageType.FORMAT_MATRIX_HTML,
body = plainTextBody.toString(),
formattedBody = replyFallbackTemplateFormatted,
body = replyFallback.toString(),
formattedBody = replyFormatted,
relatesTo = RelationDefaultContent(null, null, ReplyToContent(eventId))
)
return createEvent(roomId, content)
}

/**
* Returns a pair of <Plain Text, Formatted Text?> used for the fallback event representation
* in a reply message.
* Returns a TextContent used for the fallback event representation in a reply message.
*/
private fun bodyForReply(content: MessageContent?): Pair<String, String?> {
private fun bodyForReply(content: MessageContent?): TextContent {
when (content?.type) {
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE -> {
//If we already have formatted body, return it?
var formattedText: String? = null
if (content is MessageTextContent) {
if (content.format == MessageType.FORMAT_MATRIX_HTML) {
formattedText = content.formattedBody
}
}
return content.body to formattedText
val isReply = content.relatesTo?.inReplyTo?.eventId != null
return if (isReply)
TextContent(content.body, formattedText).removeInReplyFallbacks()
else
TextContent(content.body, formattedText)
}
MessageType.MSGTYPE_FILE -> return stringProvider.getString(R.string.reply_to_a_file) to null
MessageType.MSGTYPE_AUDIO -> return stringProvider.getString(R.string.reply_to_an_audio_file) to null
MessageType.MSGTYPE_IMAGE -> return stringProvider.getString(R.string.reply_to_an_image) to null
MessageType.MSGTYPE_VIDEO -> return stringProvider.getString(R.string.reply_to_a_video) to null
else -> return (content?.body ?: "") to null

MessageType.MSGTYPE_FILE -> return TextContent(stringProvider.getString(R.string.reply_to_a_file))
MessageType.MSGTYPE_AUDIO -> return TextContent(stringProvider.getString(R.string.reply_to_an_audio_file))
MessageType.MSGTYPE_IMAGE -> return TextContent(stringProvider.getString(R.string.reply_to_an_image))
MessageType.MSGTYPE_VIDEO -> return TextContent(stringProvider.getString(R.string.reply_to_a_video))
else -> return TextContent(content?.body ?: "")
}

}

/*
@ -365,6 +353,9 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials
companion object {
const val LOCAL_ID_PREFIX = "local."

// No whitespace
const val REPLY_PATTERN = """<mx-reply><blockquote><a href="%s">%s</a><a href="%s">%s</a><br />%s</blockquote></mx-reply>%s"""

fun isLocalEchoId(eventId: String): Boolean = eventId.startsWith(LOCAL_ID_PREFIX)
}
}

View File

@ -0,0 +1,74 @@
/*
* Copyright 2019 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.matrix.android.internal.session.room.send

import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageType

/**
* Contains a text and eventually a formatted text
*/
data class TextContent(
val text: String,

val formattedText: String? = null
) {
fun takeFormatted() = formattedText ?: text
}


fun TextContent.toMessageTextContent(): MessageTextContent {
return MessageTextContent(
type = MessageType.MSGTYPE_TEXT,
format = MessageType.FORMAT_MATRIX_HTML.takeIf { formattedText != null },
body = text,
formattedBody = formattedText
)
}

fun TextContent.removeInReplyFallbacks(): TextContent {
return copy(
text = extractUsefulTextFromReply(this.text),
formattedText = this.formattedText?.let { extractUsefulTextFromHtmlReply(it) }
)
}

private fun extractUsefulTextFromReply(repliedBody: String): String {
val lines = repliedBody.lines()
var wellFormed = repliedBody.startsWith(">")
var endOfPreviousFound = false
val usefullines = ArrayList<String>()
lines.forEach {
if (it == "") {
endOfPreviousFound = true
return@forEach
}
if (!endOfPreviousFound) {
wellFormed = wellFormed && it.startsWith(">")
} else {
usefullines.add(it)
}
}
return usefullines.joinToString("\n").takeIf { wellFormed } ?: repliedBody
}

private fun extractUsefulTextFromHtmlReply(repliedBody: String): String {
if (repliedBody.startsWith("<mx-reply>")) {
return repliedBody.substring(repliedBody.lastIndexOf("</mx-reply>") + "</mx-reply>".length).trim()
}
return repliedBody
}

View File

@ -19,7 +19,7 @@ package im.vector.matrix.android.internal.session.signout
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.signout.SignOutService
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.task.toConfigurableTask
import javax.inject.Inject

internal class DefaultSignOutService @Inject constructor(private val signOutTask: SignOutTask,
@ -27,7 +27,7 @@ internal class DefaultSignOutService @Inject constructor(private val signOutTask

override fun signOut(callback: MatrixCallback<Unit>) {
signOutTask
.configureWith(Unit)
.toConfigurableTask()
.dispatchTo(callback)
.executeBy(taskExecutor)
}

View File

@ -21,7 +21,14 @@ import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.util.Cancelable

internal fun <PARAMS, RESULT> Task<PARAMS, RESULT>.configureWith(params: PARAMS): ConfigurableTask<PARAMS, RESULT> {
return ConfigurableTask (this, params)
return ConfigurableTask(this, params)
}

/**
* Convert a Task to a ConfigurableTask without parameter
*/
internal fun <RESULT> Task<Unit, RESULT>.toConfigurableTask(): ConfigurableTask<Unit, RESULT> {
return ConfigurableTask(this, Unit)
}

internal data class ConfigurableTask<PARAMS, RESULT>(

View File

@ -38,7 +38,6 @@ import org.junit.Test

class PushrulesConditionTest {


@Test
fun test_eventmatch_type_condition() {
val condition = EventMatchCondition("type", "m.room.message")
@ -158,9 +157,9 @@ class PushrulesConditionTest {
content = MessageTextContent("m.text", "A").toContent(),
originServerTs = 0,
roomId = "3joined").also {
Assert.assertTrue("This room has 3 members",conditionEqual3.isSatisfied(it, session))
Assert.assertTrue("This room has 3 members",conditionEqual3Bis.isSatisfied(it, session))
Assert.assertFalse("This room has more than 3 members",conditionLessThan3.isSatisfied(it, session))
Assert.assertTrue("This room has 3 members", conditionEqual3.isSatisfied(it, session))
Assert.assertTrue("This room has 3 members", conditionEqual3Bis.isSatisfied(it, session))
Assert.assertFalse("This room has more than 3 members", conditionLessThan3.isSatisfied(it, session))
}
}

@ -286,7 +285,7 @@ class PushrulesConditionTest {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}

override fun replyToMessage(eventReplied: Event, replyText: String): Cancelable? {
override fun replyToMessage(eventReplied: TimelineEvent, replyText: String, autoMarkdown: Boolean): Cancelable? {
TODO("not implemented") //To change body of created functions use File | Settings | File Templates.
}


View File

@ -33,6 +33,7 @@ import android.view.View
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.lifecycle.ViewModelProviders
@ -53,11 +54,11 @@ import com.otaliastudios.autocomplete.AutocompleteCallback
import com.otaliastudios.autocomplete.CharPolicy
import im.vector.matrix.android.api.permalinks.PermalinkFactory
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
@ -90,6 +91,7 @@ import im.vector.riotx.features.home.room.detail.timeline.action.MessageMenuView
import im.vector.riotx.features.home.room.detail.timeline.action.ViewReactionBottomSheet
import im.vector.riotx.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotx.features.html.EventHtmlRenderer
import im.vector.riotx.features.html.PillImageSpan
import im.vector.riotx.features.invite.VectorInviteView
import im.vector.riotx.features.media.ImageContentRenderer
@ -104,8 +106,6 @@ import kotlinx.android.parcel.Parcelize
import kotlinx.android.synthetic.main.fragment_room_detail.*
import kotlinx.android.synthetic.main.merge_composer_layout.view.*
import org.commonmark.parser.Parser
import ru.noties.markwon.Markwon
import ru.noties.markwon.html.HtmlPlugin
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@ -178,6 +178,7 @@ class RoomDetailFragment :
@Inject lateinit var errorFormatter: ErrorFormatter
private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback
private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback
@Inject lateinit var eventHtmlRenderer: EventHtmlRenderer


override fun getLayoutResId() = R.layout.fragment_room_detail
@ -225,79 +226,53 @@ class RoomDetailFragment :
}
}

roomDetailViewModel.selectSubscribe(
RoomDetailViewState::sendMode,
RoomDetailViewState::selectedEvent,
RoomDetailViewState::roomId) { mode, event, roomId ->
roomDetailViewModel.selectSubscribe(RoomDetailViewState::sendMode) { mode ->
when (mode) {
SendMode.REGULAR -> {
commandAutocompletePolicy.enabled = true
val uid = session.sessionParams.credentials.userId
val meMember = session.getRoom(roomId)?.getRoomMember(uid)
avatarRenderer.render(meMember?.avatarUrl, uid, meMember?.displayName, composerLayout.composerAvatarImageView)
composerLayout.collapse()
}
SendMode.EDIT,
SendMode.QUOTE,
SendMode.REPLY -> {
commandAutocompletePolicy.enabled = false
if (event == null) {
//we should ignore? can this happen?
Timber.e("Enter edit mode with no event selected")
return@selectSubscribe
}
//switch to expanded bar
composerLayout.composerRelatedMessageTitle.apply {
text = event.getDisambiguatedDisplayName()
setTextColor(ContextCompat.getColor(requireContext(), getColorFromUserId(event.root.senderId)))
}

//TODO this is used at several places, find way to refactor?
val messageContent: MessageContent? =
event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.getClearContent().toModel()
val nonFormattedBody = messageContent?.body ?: ""
var formattedBody: CharSequence? = null
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
val parser = Parser.builder().build()
val document = parser.parse(messageContent.formattedBody
?: messageContent.body)
formattedBody = Markwon.builder(requireContext())
.usePlugin(HtmlPlugin.create()).build().render(document)
}
composerLayout.composerRelatedMessageContent.text = formattedBody
?: nonFormattedBody


if (mode == SendMode.EDIT) {
//TODO if it's a reply we should trim the top part of message
composerLayout.composerEditText.setText(nonFormattedBody)
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_edit))
} else if (mode == SendMode.QUOTE) {
composerLayout.composerEditText.setText("")
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_quote))
} else if (mode == SendMode.REPLY) {
composerLayout.composerEditText.setText("")
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), R.drawable.ic_reply))
}

avatarRenderer.render(event.senderAvatar, event.root.senderId
?: "", event.senderName, composerLayout.composerRelatedMessageAvatar)

composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
composerLayout.expand {
focusComposerAndShowKeyboard()
}
composerLayout.composerRelatedMessageCloseButton.setOnClickListener {
composerLayout.composerEditText.setText("")
roomDetailViewModel.resetSendMode()
}

}
SendMode.REGULAR -> exitSpecialMode()
is SendMode.EDIT -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_edit, true)
is SendMode.QUOTE -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_quote, false)
is SendMode.REPLY -> enterSpecialMode(mode.timelineEvent, R.drawable.ic_reply, false)
}
}
}

private fun exitSpecialMode() {
commandAutocompletePolicy.enabled = true
composerLayout.collapse()
}

private fun enterSpecialMode(event: TimelineEvent, @DrawableRes iconRes: Int, useText: Boolean) {
commandAutocompletePolicy.enabled = false
//switch to expanded bar
composerLayout.composerRelatedMessageTitle.apply {
text = event.getDisambiguatedDisplayName()
setTextColor(ContextCompat.getColor(requireContext(), getColorFromUserId(event.root.senderId)))
}

val messageContent: MessageContent? = event.getLastMessageContent()
val nonFormattedBody = messageContent?.body ?: ""
var formattedBody: CharSequence? = null
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
val parser = Parser.builder().build()
val document = parser.parse(messageContent.formattedBody
?: messageContent.body)
formattedBody = eventHtmlRenderer.render(document)
}
composerLayout.composerRelatedMessageContent.text = formattedBody
?: nonFormattedBody

composerLayout.composerEditText.setText(if (useText) nonFormattedBody else "")
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))

avatarRenderer.render(event.senderAvatar, event.root.senderId
?: "", event.senderName, composerLayout.composerRelatedMessageAvatar)

composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
composerLayout.expand {
focusComposerAndShowKeyboard()
}
}

override fun onResume() {
super.onResume()

@ -422,6 +397,10 @@ class RoomDetailFragment :
roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage, VectorPreferences.isMarkdownEnabled(requireContext())))
}
}
composerLayout.composerRelatedMessageCloseButton.setOnClickListener {
composerLayout.composerEditText.setText("")
roomDetailViewModel.resetSendMode()
}
}

private fun setupAttachmentButton() {

View File

@ -47,8 +47,6 @@ import im.vector.riotx.core.utils.LiveEvent
import im.vector.riotx.features.command.CommandParser
import im.vector.riotx.features.command.ParsedCommand
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineDisplayableEvents
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.rxkotlin.subscribeBy
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
@ -126,11 +124,10 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}

fun enterEditMode(event: TimelineEvent) {
private fun enterEditMode(event: TimelineEvent) {
setState {
copy(
sendMode = SendMode.EDIT,
selectedEvent = event
sendMode = SendMode.EDIT(event)
)
}
}
@ -138,8 +135,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
fun resetSendMode() {
setState {
copy(
sendMode = SendMode.REGULAR,
selectedEvent = null
sendMode = SendMode.REGULAR
)
}
}
@ -167,7 +163,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
withState { state ->
when (state.sendMode) {
SendMode.REGULAR -> {
SendMode.REGULAR -> {
val slashCommandResult = CommandParser.parseSplashCommand(action.text)

when (slashCommandResult) {
@ -233,21 +229,29 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}
}
SendMode.EDIT -> {
room.editTextMessage(state.selectedEvent?.root?.eventId
?: "", action.text, action.autoMarkdown)
is SendMode.EDIT -> {
val messageContent: MessageContent? =
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val nonFormattedBody = messageContent?.body ?: ""

if (nonFormattedBody != action.text) {
room.editTextMessage(state.sendMode.timelineEvent.root.eventId
?: "", action.text, action.autoMarkdown)
} else {
Timber.w("Same message content, do not send edition")
}
setState {
copy(
sendMode = SendMode.REGULAR,
selectedEvent = null
sendMode = SendMode.REGULAR
)
}
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
}
SendMode.QUOTE -> {
is SendMode.QUOTE -> {
val messageContent: MessageContent? =
state.selectedEvent?.annotations?.editSummary?.aggregatedContent?.toModel()
?: state.selectedEvent?.root?.getClearContent().toModel()
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
val textMsg = messageContent?.body

val finalText = legacyRiotQuoteText(textMsg, action.text)
@ -264,19 +268,17 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
setState {
copy(
sendMode = SendMode.REGULAR,
selectedEvent = null
sendMode = SendMode.REGULAR
)
}
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
}
SendMode.REPLY -> {
state.selectedEvent?.let {
room.replyToMessage(it.root, action.text)
is SendMode.REPLY -> {
state.sendMode.timelineEvent.let {
room.replyToMessage(it, action.text, action.autoMarkdown)
setState {
copy(
sendMode = SendMode.REGULAR,
selectedEvent = null
sendMode = SendMode.REGULAR
)
}
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
@ -427,8 +429,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
room.getTimeLineEvent(action.eventId)?.let {
setState {
copy(
sendMode = SendMode.QUOTE,
selectedEvent = it
sendMode = SendMode.QUOTE(it)
)
}
}
@ -438,8 +439,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
room.getTimeLineEvent(action.eventId)?.let {
setState {
copy(
sendMode = SendMode.REPLY,
selectedEvent = it
sendMode = SendMode.REPLY(it)
)
}
}

View File

@ -32,11 +32,11 @@ import im.vector.matrix.android.api.session.user.model.User
*
* Depending on the state the bottom toolbar will change (icons/preview/actions...)
*/
enum class SendMode {
REGULAR,
QUOTE,
EDIT,
REPLY
sealed class SendMode {
object REGULAR : SendMode()
data class QUOTE(val timelineEvent: TimelineEvent) : SendMode()
data class EDIT(val timelineEvent: TimelineEvent) : SendMode()
data class REPLY(val timelineEvent: TimelineEvent) : SendMode()
}

data class RoomDetailViewState(
@ -46,7 +46,6 @@ data class RoomDetailViewState(
val asyncInviter: Async<User> = Uninitialized,
val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val sendMode: SendMode = SendMode.REGULAR,
val selectedEvent: TimelineEvent? = null,
val isEncrypted: Boolean = false
) : MvRxState {


View File

@ -21,11 +21,11 @@ import com.squareup.inject.assisted.AssistedInject
import dagger.Lazy
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
import im.vector.matrix.rx.RxRoom
import im.vector.riotx.core.extensions.canReact
import im.vector.riotx.core.platform.VectorViewModel
@ -58,8 +58,7 @@ data class MessageActionState(
fun messageBody(eventHtmlRenderer: EventHtmlRenderer?, noticeEventFormatter: NoticeEventFormatter?): CharSequence? {
return when (timelineEvent()?.root?.getClearType()) {
EventType.MESSAGE -> {
val messageContent: MessageContent? = timelineEvent()?.annotations?.editSummary?.aggregatedContent?.toModel()
?: timelineEvent()?.root?.getClearContent().toModel()
val messageContent: MessageContent? = timelineEvent()?.getLastMessageContent()
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
eventHtmlRenderer?.render(messageContent.formattedBody
?: messageContent.body)

View File

@ -27,11 +27,11 @@ import dagger.Lazy
import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
import im.vector.matrix.android.api.session.events.model.RelationType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
import im.vector.riotx.EmojiCompatFontProvider
import im.vector.riotx.R
@ -79,8 +79,7 @@ class MessageItemFactory @Inject constructor(
}

val messageContent: MessageContent =
event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.getClearContent().toModel()
event.getLastMessageContent()
?: //Malformed content, we should echo something on screen
return DefaultItem_().text(stringProvider.getString(R.string.malformed_message))

@ -311,8 +310,7 @@ class MessageItemFactory @Inject constructor(
editSummary: EditAggregatedSummary?): SpannableStringBuilder {
val spannable = SpannableStringBuilder()
spannable.append(linkifiedBody)
// TODO i18n
val editedSuffix = "(edited)"
val editedSuffix = stringProvider.getString(R.string.edited_suffix)
spannable.append(" ").append(editedSuffix)
val color = colorProvider.getColorFromAttribute(R.attr.vctr_list_header_secondary_text_color)
val editStart = spannable.indexOf(editedSuffix)

View File

@ -50,6 +50,10 @@ class EventHtmlRenderer @Inject constructor(context: Context,
return markwon.toMarkdown(text)
}

fun render(node: Node) : CharSequence {
return markwon.render(node)
}

}

private class MatrixPlugin private constructor(private val glideRequests: GlideRequests,

View File

@ -18,15 +18,14 @@ package im.vector.riotx.features.notifications
import androidx.core.app.NotificationCompat
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.crypto.MXCryptoError
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.Membership
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
import im.vector.riotx.BuildConfig
import im.vector.riotx.R
@ -94,8 +93,8 @@ class NotifiableEventResolver @Inject constructor(private val stringProvider: St
Timber.e("## Unable to resolve room for eventId [${event}]")
// Ok room is not known in store, but we can still display something
val body =
event.annotations?.editSummary?.aggregatedContent?.toModel<MessageContent>()?.body
?: event.root.getClearContent().toModel<MessageContent>()?.body
event.getLastMessageContent()
?.body
?: stringProvider.getString(R.string.notification_unknown_new_event)
val roomName = stringProvider.getString(R.string.notification_unknown_room_name)
val senderDisplayName = event.senderName ?: event.root.senderId
@ -129,8 +128,8 @@ class NotifiableEventResolver @Inject constructor(private val stringProvider: St
}
}

val body = event.annotations?.editSummary?.aggregatedContent?.toModel<MessageContent>()?.body
?: event.root.getClearContent().toModel<MessageContent>()?.body
val body = event.getLastMessageContent()
?.body
?: stringProvider.getString(R.string.notification_unknown_new_event)
val roomName = room.roomSummary()?.displayName ?: ""
val senderDisplayName = event.senderName ?: event.root.senderId

View File

@ -14,5 +14,7 @@
<string name="downloading_file">Downloading file %1$s…</string>
<string name="downloaded_file">File %1$s has been downloaded!</string>

<string name="edited_suffix">"(edited)"</string>


</resources>