mirror of
https://github.com/element-hq/element-android
synced 2024-11-28 05:31:21 +03:00
Fix / edit of reply and edit of edit of reply
This commit is contained in:
parent
42584fc55a
commit
6effb90361
7 changed files with 139 additions and 39 deletions
|
@ -80,6 +80,20 @@ interface RelationService {
|
||||||
newBodyAutoMarkdown: Boolean,
|
newBodyAutoMarkdown: Boolean,
|
||||||
compatibilityBodyText: String = "* $newBodyText"): Cancelable
|
compatibilityBodyText: String = "* $newBodyText"): Cancelable
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a reply. This is a special case because replies contains fallback text as a prefix.
|
||||||
|
* This method will take the new body (stripped from fallbacks) and re-add them before sending.
|
||||||
|
* @param targetEventId The event to edit
|
||||||
|
* @param newBodyText The edited body (stripped from in reply to content)
|
||||||
|
* @param compatibilityBodyText The text that will appear on clients that don't support yet edition
|
||||||
|
*/
|
||||||
|
fun editReply(replyToEdit: TimelineEvent,
|
||||||
|
originalSenderId: String?,
|
||||||
|
originalEventId : String,
|
||||||
|
newBodyText: String,
|
||||||
|
compatibilityBodyText: String = "* $newBodyText"): Cancelable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get's the edit history of the given event
|
* Get's the edit history of the given event
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -22,6 +22,7 @@ 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.EventAnnotationsSummary
|
||||||
import im.vector.matrix.android.api.session.room.model.message.MessageContent
|
import im.vector.matrix.android.api.session.room.model.message.MessageContent
|
||||||
import im.vector.matrix.android.api.session.room.send.SendState
|
import im.vector.matrix.android.api.session.room.send.SendState
|
||||||
|
import im.vector.matrix.android.internal.session.room.send.extractUsefulTextFromReply
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This data class is a wrapper around an Event. It allows to get useful data in the context of a timeline.
|
* This data class is a wrapper around an Event. It allows to get useful data in the context of a timeline.
|
||||||
|
@ -88,3 +89,15 @@ data class TimelineEvent(
|
||||||
*/
|
*/
|
||||||
fun TimelineEvent.getLastMessageContent(): MessageContent? = annotations?.editSummary?.aggregatedContent?.toModel()
|
fun TimelineEvent.getLastMessageContent(): MessageContent? = annotations?.editSummary?.aggregatedContent?.toModel()
|
||||||
?: root.getClearContent().toModel()
|
?: root.getClearContent().toModel()
|
||||||
|
|
||||||
|
|
||||||
|
fun TimelineEvent.getTextEditableContent(): String? {
|
||||||
|
val originalContent = root.getClearContent().toModel<MessageContent>() ?: return null
|
||||||
|
val isReply = originalContent.relatesTo?.inReplyTo != null
|
||||||
|
val lastContent = getLastMessageContent()
|
||||||
|
return if (isReply) {
|
||||||
|
return extractUsefulTextFromReply(lastContent?.body ?: "")
|
||||||
|
} else {
|
||||||
|
lastContent?.body ?: ""
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,6 +25,7 @@ import im.vector.matrix.android.api.auth.data.Credentials
|
||||||
import im.vector.matrix.android.api.session.crypto.CryptoService
|
import im.vector.matrix.android.api.session.crypto.CryptoService
|
||||||
import im.vector.matrix.android.api.session.events.model.Event
|
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.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.model.relation.RelationService
|
||||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||||
import im.vector.matrix.android.api.util.Cancelable
|
import im.vector.matrix.android.api.util.Cancelable
|
||||||
|
@ -132,6 +133,24 @@ internal class DefaultRelationService @Inject constructor(private val context: C
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun editReply(replyToEdit: TimelineEvent,
|
||||||
|
originalSenderId: String?,
|
||||||
|
originalEventId: String,
|
||||||
|
newBodyText: String,
|
||||||
|
compatibilityBodyText: String): Cancelable {
|
||||||
|
val event = eventFactory
|
||||||
|
.createReplaceTextOfReply(roomId,
|
||||||
|
replyToEdit,
|
||||||
|
originalSenderId, originalEventId,
|
||||||
|
newBodyText, true, MessageType.MSGTYPE_TEXT, compatibilityBodyText)
|
||||||
|
.also {
|
||||||
|
saveLocalEcho(it)
|
||||||
|
}
|
||||||
|
val workRequest = createSendEventWork(event)
|
||||||
|
TimelineSendEventWorkCommon.postWork(context, roomId, workRequest)
|
||||||
|
return CancelableWork(context, workRequest.id)
|
||||||
|
}
|
||||||
|
|
||||||
override fun fetchEditHistory(eventId: String, callback: MatrixCallback<List<Event>>) {
|
override fun fetchEditHistory(eventId: String, callback: MatrixCallback<List<Event>>) {
|
||||||
val params = FetchEditHistoryTask.Params(roomId, eventId)
|
val params = FetchEditHistoryTask.Params(roomId, eventId)
|
||||||
fetchEditHistoryTask.configureWith(params)
|
fetchEditHistoryTask.configureWith(params)
|
||||||
|
|
|
@ -104,6 +104,45 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createReplaceTextOfReply(roomId: String, eventReplaced: TimelineEvent,
|
||||||
|
originalSenderId: String?,
|
||||||
|
originalEventId: String,
|
||||||
|
newBodyText: String,
|
||||||
|
newBodyAutoMarkdown: Boolean,
|
||||||
|
msgType: String,
|
||||||
|
compatibilityText: String): Event {
|
||||||
|
val permalink = PermalinkFactory.createPermalink(roomId, originalEventId)
|
||||||
|
val userLink = originalSenderId?.let { PermalinkFactory.createPermalink(it) } ?: ""
|
||||||
|
|
||||||
|
val body = bodyForReply(eventReplaced.getLastMessageContent(), eventReplaced.root.getClearContent().toModel())
|
||||||
|
val replyFormatted = REPLY_PATTERN.format(
|
||||||
|
permalink,
|
||||||
|
stringProvider.getString(R.string.message_reply_to_prefix),
|
||||||
|
userLink,
|
||||||
|
originalSenderId,
|
||||||
|
body.takeFormatted(),
|
||||||
|
createTextContent(newBodyText, newBodyAutoMarkdown).takeFormatted()
|
||||||
|
)
|
||||||
|
//
|
||||||
|
// > <@alice:example.org> This is the original body
|
||||||
|
//
|
||||||
|
val replyFallback = buildReplyFallback(body, originalSenderId, newBodyText)
|
||||||
|
|
||||||
|
return createEvent(roomId,
|
||||||
|
MessageTextContent(
|
||||||
|
type = msgType,
|
||||||
|
body = compatibilityText,
|
||||||
|
relatesTo = RelationDefaultContent(RelationType.REPLACE, eventReplaced.root.eventId),
|
||||||
|
newContent = MessageTextContent(
|
||||||
|
type = msgType,
|
||||||
|
format = MessageType.FORMAT_MATRIX_HTML,
|
||||||
|
body = replyFallback,
|
||||||
|
formattedBody = replyFormatted
|
||||||
|
)
|
||||||
|
.toContent()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fun createMediaEvent(roomId: String, attachment: ContentAttachmentData): Event {
|
fun createMediaEvent(roomId: String, attachment: ContentAttachmentData): Event {
|
||||||
return when (attachment.type) {
|
return when (attachment.type) {
|
||||||
ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment)
|
ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment)
|
||||||
|
@ -239,16 +278,8 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials
|
||||||
val permalink = PermalinkFactory.createPermalink(eventReplied.root) ?: return null
|
val permalink = PermalinkFactory.createPermalink(eventReplied.root) ?: return null
|
||||||
val userId = eventReplied.root.senderId ?: return null
|
val userId = eventReplied.root.senderId ?: return null
|
||||||
val userLink = PermalinkFactory.createPermalink(userId) ?: return null
|
val userLink = PermalinkFactory.createPermalink(userId) ?: return null
|
||||||
// <mx-reply>
|
|
||||||
// <blockquote>
|
val body = bodyForReply(eventReplied.getLastMessageContent(), eventReplied.root.getClearContent().toModel())
|
||||||
// <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(
|
val replyFormatted = REPLY_PATTERN.format(
|
||||||
permalink,
|
permalink,
|
||||||
stringProvider.getString(R.string.message_reply_to_prefix),
|
stringProvider.getString(R.string.message_reply_to_prefix),
|
||||||
|
@ -260,8 +291,22 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials
|
||||||
//
|
//
|
||||||
// > <@alice:example.org> This is the original body
|
// > <@alice:example.org> This is the original body
|
||||||
//
|
//
|
||||||
|
val replyFallback = buildReplyFallback(body, userId, replyText)
|
||||||
|
|
||||||
|
val eventId = eventReplied.root.eventId ?: return null
|
||||||
|
val content = MessageTextContent(
|
||||||
|
type = MessageType.MSGTYPE_TEXT,
|
||||||
|
format = MessageType.FORMAT_MATRIX_HTML,
|
||||||
|
body = replyFallback,
|
||||||
|
formattedBody = replyFormatted,
|
||||||
|
relatesTo = RelationDefaultContent(null, null, ReplyToContent(eventId))
|
||||||
|
)
|
||||||
|
return createEvent(roomId, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildReplyFallback(body: TextContent, originalSenderId: String?, newBodyText: String): String {
|
||||||
val lines = body.text.split("\n")
|
val lines = body.text.split("\n")
|
||||||
val replyFallback = StringBuffer("><$userId>")
|
val replyFallback = StringBuffer("><$originalSenderId>")
|
||||||
lines.forEachIndexed { index, s ->
|
lines.forEachIndexed { index, s ->
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
replyFallback.append(" $s")
|
replyFallback.append(" $s")
|
||||||
|
@ -269,23 +314,16 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials
|
||||||
replyFallback.append("\n>$s")
|
replyFallback.append("\n>$s")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
replyFallback.append("\n\n").append(replyText)
|
replyFallback.append("\n\n").append(newBodyText)
|
||||||
|
return replyFallback.toString()
|
||||||
val eventId = eventReplied.root.eventId ?: return null
|
|
||||||
val content = MessageTextContent(
|
|
||||||
type = MessageType.MSGTYPE_TEXT,
|
|
||||||
format = MessageType.FORMAT_MATRIX_HTML,
|
|
||||||
body = replyFallback.toString(),
|
|
||||||
formattedBody = replyFormatted,
|
|
||||||
relatesTo = RelationDefaultContent(null, null, ReplyToContent(eventId))
|
|
||||||
)
|
|
||||||
return createEvent(roomId, content)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a TextContent used for the fallback event representation in a reply message.
|
* Returns a TextContent used for the fallback event representation in a reply message.
|
||||||
|
* We also pass the original content, because in case of an edit of a reply the last content is not
|
||||||
|
* himself a reply, but it will contain the fallbacks, so we have to trim them.
|
||||||
*/
|
*/
|
||||||
private fun bodyForReply(content: MessageContent?): TextContent {
|
private fun bodyForReply(content: MessageContent?, originalContent: MessageContent?): TextContent {
|
||||||
when (content?.type) {
|
when (content?.type) {
|
||||||
MessageType.MSGTYPE_EMOTE,
|
MessageType.MSGTYPE_EMOTE,
|
||||||
MessageType.MSGTYPE_TEXT,
|
MessageType.MSGTYPE_TEXT,
|
||||||
|
@ -296,7 +334,8 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials
|
||||||
formattedText = content.formattedBody
|
formattedText = content.formattedBody
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val isReply = content.relatesTo?.inReplyTo?.eventId != null
|
val isReply = content.relatesTo?.inReplyTo?.eventId != null ||
|
||||||
|
originalContent?.relatesTo?.inReplyTo?.eventId != null
|
||||||
return if (isReply)
|
return if (isReply)
|
||||||
TextContent(content.body, formattedText).removeInReplyFallbacks()
|
TextContent(content.body, formattedText).removeInReplyFallbacks()
|
||||||
else
|
else
|
||||||
|
@ -353,7 +392,16 @@ internal class LocalEchoEventFactory @Inject constructor(private val credentials
|
||||||
companion object {
|
companion object {
|
||||||
const val LOCAL_ID_PREFIX = "local."
|
const val LOCAL_ID_PREFIX = "local."
|
||||||
|
|
||||||
// No whitespace
|
|
||||||
|
// <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>
|
||||||
|
// No whitespace because currently breaks temporary formatted text to Span
|
||||||
const val REPLY_PATTERN = """<mx-reply><blockquote><a href="%s">%s</a><a href="%s">%s</a><br />%s</blockquote></mx-reply>%s"""
|
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)
|
fun isLocalEchoId(eventId: String): Boolean = eventId.startsWith(LOCAL_ID_PREFIX)
|
||||||
|
|
|
@ -47,7 +47,7 @@ fun TextContent.removeInReplyFallbacks(): TextContent {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractUsefulTextFromReply(repliedBody: String): String {
|
fun extractUsefulTextFromReply(repliedBody: String): String {
|
||||||
val lines = repliedBody.lines()
|
val lines = repliedBody.lines()
|
||||||
var wellFormed = repliedBody.startsWith(">")
|
var wellFormed = repliedBody.startsWith(">")
|
||||||
var endOfPreviousFound = false
|
var endOfPreviousFound = false
|
||||||
|
@ -66,7 +66,7 @@ private fun extractUsefulTextFromReply(repliedBody: String): String {
|
||||||
return usefullines.joinToString("\n").takeIf { wellFormed } ?: repliedBody
|
return usefullines.joinToString("\n").takeIf { wellFormed } ?: repliedBody
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractUsefulTextFromHtmlReply(repliedBody: String): String {
|
fun extractUsefulTextFromHtmlReply(repliedBody: String): String {
|
||||||
if (repliedBody.startsWith("<mx-reply>")) {
|
if (repliedBody.startsWith("<mx-reply>")) {
|
||||||
return repliedBody.substring(repliedBody.lastIndexOf("</mx-reply>") + "</mx-reply>".length).trim()
|
return repliedBody.substring(repliedBody.lastIndexOf("</mx-reply>") + "</mx-reply>".length).trim()
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,6 +59,7 @@ 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.model.message.*
|
||||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
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.room.timeline.getLastMessageContent
|
||||||
|
import im.vector.matrix.android.api.session.room.timeline.getTextEditableContent
|
||||||
import im.vector.matrix.android.api.session.user.model.User
|
import im.vector.matrix.android.api.session.user.model.User
|
||||||
import im.vector.riotx.R
|
import im.vector.riotx.R
|
||||||
import im.vector.riotx.core.di.ScreenComponent
|
import im.vector.riotx.core.di.ScreenComponent
|
||||||
|
@ -258,7 +259,7 @@ class RoomDetailFragment :
|
||||||
composerLayout.composerRelatedMessageContent.text = formattedBody
|
composerLayout.composerRelatedMessageContent.text = formattedBody
|
||||||
?: nonFormattedBody
|
?: nonFormattedBody
|
||||||
|
|
||||||
composerLayout.composerEditText.setText(if (useText) nonFormattedBody else "")
|
composerLayout.composerEditText.setText(if (useText) event.getTextEditableContent() else "")
|
||||||
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
|
composerLayout.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes))
|
||||||
|
|
||||||
avatarRenderer.render(event.senderAvatar, event.root.senderId
|
avatarRenderer.render(event.senderAvatar, event.root.senderId
|
||||||
|
|
|
@ -39,7 +39,6 @@ import im.vector.matrix.android.api.session.room.model.message.getFileUrl
|
||||||
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
|
||||||
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
|
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
|
||||||
import im.vector.matrix.rx.rx
|
import im.vector.matrix.rx.rx
|
||||||
import im.vector.riotx.R
|
|
||||||
import im.vector.riotx.core.intent.getFilenameFromUri
|
import im.vector.riotx.core.intent.getFilenameFromUri
|
||||||
import im.vector.riotx.core.platform.VectorViewModel
|
import im.vector.riotx.core.platform.VectorViewModel
|
||||||
import im.vector.riotx.core.resources.UserPreferencesProvider
|
import im.vector.riotx.core.resources.UserPreferencesProvider
|
||||||
|
@ -52,8 +51,6 @@ import org.commonmark.parser.Parser
|
||||||
import org.commonmark.renderer.html.HtmlRenderer
|
import org.commonmark.renderer.html.HtmlRenderer
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.*
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
|
||||||
|
@ -229,16 +226,24 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is SendMode.EDIT -> {
|
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) {
|
//is original event a reply?
|
||||||
room.editTextMessage(state.sendMode.timelineEvent.root.eventId
|
val inReplyTo = state.sendMode.timelineEvent.root.getClearContent().toModel<MessageContent>()?.relatesTo?.inReplyTo?.eventId
|
||||||
?: "", messageContent?.type ?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown)
|
if (inReplyTo != null) {
|
||||||
|
//TODO check if same content?
|
||||||
|
room.editReply(state.sendMode.timelineEvent, room.getTimeLineEvent(inReplyTo)?.root?.senderId, inReplyTo, action.text)
|
||||||
} else {
|
} else {
|
||||||
Timber.w("Same message content, do not send edition")
|
val messageContent: MessageContent? =
|
||||||
|
state.sendMode.timelineEvent.annotations?.editSummary?.aggregatedContent.toModel()
|
||||||
|
?: state.sendMode.timelineEvent.root.getClearContent().toModel()
|
||||||
|
val existingBody = messageContent?.body ?: ""
|
||||||
|
if (existingBody != action.text) {
|
||||||
|
room.editTextMessage(state.sendMode.timelineEvent.root.eventId
|
||||||
|
?: "", messageContent?.type
|
||||||
|
?: MessageType.MSGTYPE_TEXT, action.text, action.autoMarkdown)
|
||||||
|
} else {
|
||||||
|
Timber.w("Same message content, do not send edition")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setState {
|
setState {
|
||||||
copy(
|
copy(
|
||||||
|
|
Loading…
Reference in a new issue