diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index c04907ab..1a0c0f91 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -48,7 +48,7 @@ android { buildConfigField "boolean", "LOG_PRIVATE_DATA", "false" // Set to BODY instead of NONE to enable logging - buildConfigField "okhttp3.logging.HttpLoggingInterceptor.Level", "OKHTTP_LOGGING_LEVEL", "okhttp3.logging.HttpLoggingInterceptor.Level.NONE" + buildConfigField "okhttp3.logging.HttpLoggingInterceptor.Level", "OKHTTP_LOGGING_LEVEL", "okhttp3.logging.HttpLoggingInterceptor.Level.BASIC" } release { @@ -91,6 +91,7 @@ dependencies { def moshi_version = '1.8.0' def lifecycle_version = '2.0.0' def coroutines_version = "1.0.1" + def markwon_version = '3.0.0-SNAPSHOT' implementation fileTree(dir: 'libs', include: ['*.aar']) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" @@ -112,6 +113,8 @@ dependencies { implementation "com.squareup.moshi:moshi-adapters:$moshi_version" kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version" + implementation "ru.noties.markwon:core:$markwon_version" + // Database implementation 'com.github.Zhuinden:realm-monarchy:0.5.1' kapt 'dk.ilios:realmfieldnameshelper:1.1.1' diff --git a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineTest.kt b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineTest.kt index e81e481d..221887c7 100644 --- a/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineTest.kt +++ b/matrix-sdk-android/src/androidTest/java/im/vector/matrix/android/session/room/timeline/TimelineTest.kt @@ -18,12 +18,10 @@ package im.vector.matrix.android.session.room.timeline import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.InstrumentedTest -import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.internal.database.model.SessionRealmModule import im.vector.matrix.android.internal.session.room.EventRelationExtractor -import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater import im.vector.matrix.android.internal.session.room.membership.SenderRoomMemberExtractor import im.vector.matrix.android.internal.session.room.timeline.DefaultTimeline import im.vector.matrix.android.internal.session.room.timeline.TimelineEventFactory @@ -60,8 +58,7 @@ internal class TimelineTest : InstrumentedTest { private fun createTimeline(initialEventId: String? = null): Timeline { val taskExecutor = TaskExecutor(testCoroutineDispatchers) - val erau = EventRelationsAggregationUpdater(Credentials("", "", "", null, null)) - val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy, erau) + val tokenChunkEventPersistor = TokenChunkEventPersistor(monarchy) val paginationTask = FakePaginationTask(tokenChunkEventPersistor) val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor) val roomMemberExtractor = SenderRoomMemberExtractor(ROOM_ID) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt index ae890eaf..86965ed4 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/Room.kt @@ -19,7 +19,7 @@ package im.vector.matrix.android.api.session.room import androidx.lifecycle.LiveData import im.vector.matrix.android.api.session.room.members.MembershipService import im.vector.matrix.android.api.session.room.model.RoomSummary -import im.vector.matrix.android.api.session.room.model.annotation.ReactionService +import im.vector.matrix.android.api.session.room.model.relation.RelationService import im.vector.matrix.android.api.session.room.read.ReadService import im.vector.matrix.android.api.session.room.send.SendService import im.vector.matrix.android.api.session.room.state.StateService @@ -28,7 +28,7 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineService /** * This interface defines methods to interact within a room. */ -interface Room : TimelineService, SendService, ReadService, MembershipService, StateService , ReactionService{ +interface Room : TimelineService, SendService, ReadService, MembershipService, StateService , RelationService{ /** * The roomId of this room diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/EditAggregatedSummary.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/EditAggregatedSummary.kt index 636343a5..759ba1ec 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/EditAggregatedSummary.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/EditAggregatedSummary.kt @@ -21,5 +21,6 @@ data class EditAggregatedSummary( val aggregatedContent: Content? = null, // The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk) val sourceEvents: List, + val localEchos: List, val lastEditTs: Long = 0 ) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/ReactionAggregatedSummary.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/ReactionAggregatedSummary.kt index a6948cd1..1fbe58c7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/ReactionAggregatedSummary.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/ReactionAggregatedSummary.kt @@ -5,5 +5,6 @@ data class ReactionAggregatedSummary( val count: Int, // 8 val addedByMe: Boolean, // true val firstTimestamp: Long, // unix timestamp - val sourceEvents: List + val sourceEvents: List, + val localEchoEvents: List ) \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/ReactionService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/ReactionService.kt deleted file mode 100644 index ace61159..00000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/ReactionService.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * 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.api.session.room.model.annotation - -import im.vector.matrix.android.api.util.Cancelable - -interface ReactionService { - - - /** - * Sends a reaction (emoji) to the targetedEvent. - * @param reaction the reaction (preferably emoji) - * @param targetEventId the id of the event being reacted - */ - fun sendReaction(reaction: String, targetEventId: String): Cancelable - - - /** - * Undo a reaction (emoji) to the targetedEvent. - * @param reaction the reaction (preferably emoji) - * @param targetEventId the id of the event being reacted - * @param myUserId used to know if a reaction event was made by the user - */ - fun undoReaction(reaction: String, targetEventId: String, myUserId: String)//: Cancelable - - - /** - * Update a quick reaction (toggle). - * If you have reacted with agree and then you click on disagree, this call will delete(redact) - * the disagree and add the agree - * If you click on a reaction that you already reacted with, it will undo it - * @param reaction the reaction (preferably emoji) - * @param oppositeReaction the opposite reaction(preferably emoji) - * @param targetEventId the id of the event being reacted - * @param myUserId used to know if a reaction event was made by the user - */ - fun updateQuickReaction(reaction: String, oppositeReaction: String, targetEventId: String, myUserId: String) - -} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/RelationContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/RelationContent.kt deleted file mode 100644 index d9e23b30..00000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/RelationContent.kt +++ /dev/null @@ -1,6 +0,0 @@ -package im.vector.matrix.android.api.session.room.model.annotation - -interface RelationContent { - val type: String? - val eventId: String? -} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageAudioContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageAudioContent.kt index f1df2b58..9b33b007 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageAudioContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageAudioContent.kt @@ -19,7 +19,7 @@ package im.vector.matrix.android.api.session.room.model.message import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import im.vector.matrix.android.api.session.events.model.Content -import im.vector.matrix.android.api.session.room.model.annotation.RelationDefaultContent +import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent @JsonClass(generateAdapter = true) data class MessageAudioContent( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageContent.kt index efc9af06..c45e47fc 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageContent.kt @@ -17,7 +17,7 @@ package im.vector.matrix.android.api.session.room.model.message import im.vector.matrix.android.api.session.events.model.Content -import im.vector.matrix.android.api.session.room.model.annotation.RelationDefaultContent +import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent interface MessageContent { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageDefaultContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageDefaultContent.kt index 4fb96a83..45ce9542 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageDefaultContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageDefaultContent.kt @@ -19,7 +19,7 @@ package im.vector.matrix.android.api.session.room.model.message import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import im.vector.matrix.android.api.session.events.model.Content -import im.vector.matrix.android.api.session.room.model.annotation.RelationDefaultContent +import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent @JsonClass(generateAdapter = true) data class MessageDefaultContent( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEmoteContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEmoteContent.kt index 8053fde4..88fd3bc1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEmoteContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEmoteContent.kt @@ -19,7 +19,7 @@ package im.vector.matrix.android.api.session.room.model.message import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import im.vector.matrix.android.api.session.events.model.Content -import im.vector.matrix.android.api.session.room.model.annotation.RelationDefaultContent +import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent @JsonClass(generateAdapter = true) data class MessageEmoteContent( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageFileContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageFileContent.kt index 57039d64..8f58294c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageFileContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageFileContent.kt @@ -19,7 +19,7 @@ package im.vector.matrix.android.api.session.room.model.message import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import im.vector.matrix.android.api.session.events.model.Content -import im.vector.matrix.android.api.session.room.model.annotation.RelationDefaultContent +import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent @JsonClass(generateAdapter = true) data class MessageFileContent( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageImageContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageImageContent.kt index 394b9879..2c978b97 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageImageContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageImageContent.kt @@ -19,7 +19,7 @@ package im.vector.matrix.android.api.session.room.model.message import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import im.vector.matrix.android.api.session.events.model.Content -import im.vector.matrix.android.api.session.room.model.annotation.RelationDefaultContent +import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent @JsonClass(generateAdapter = true) data class MessageImageContent( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageLocationContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageLocationContent.kt index d3a81a93..ddd67af9 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageLocationContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageLocationContent.kt @@ -19,7 +19,7 @@ package im.vector.matrix.android.api.session.room.model.message import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import im.vector.matrix.android.api.session.events.model.Content -import im.vector.matrix.android.api.session.room.model.annotation.RelationDefaultContent +import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent @JsonClass(generateAdapter = true) data class MessageLocationContent( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageNoticeContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageNoticeContent.kt index 53adaf9b..54037c60 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageNoticeContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageNoticeContent.kt @@ -19,7 +19,7 @@ package im.vector.matrix.android.api.session.room.model.message import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import im.vector.matrix.android.api.session.events.model.Content -import im.vector.matrix.android.api.session.room.model.annotation.RelationDefaultContent +import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent @JsonClass(generateAdapter = true) data class MessageNoticeContent( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageTextContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageTextContent.kt index 7942c259..3256d830 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageTextContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageTextContent.kt @@ -19,7 +19,7 @@ package im.vector.matrix.android.api.session.room.model.message import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import im.vector.matrix.android.api.session.events.model.Content -import im.vector.matrix.android.api.session.room.model.annotation.RelationDefaultContent +import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent @JsonClass(generateAdapter = true) data class MessageTextContent( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageVideoContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageVideoContent.kt index 0ca7def1..40c29942 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageVideoContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageVideoContent.kt @@ -19,7 +19,7 @@ package im.vector.matrix.android.api.session.room.model.message import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import im.vector.matrix.android.api.session.events.model.Content -import im.vector.matrix.android.api.session.room.model.annotation.RelationDefaultContent +import im.vector.matrix.android.api.session.room.model.relation.RelationDefaultContent @JsonClass(generateAdapter = true) data class MessageVideoContent( diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/ReactionContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/ReactionContent.kt similarity index 75% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/ReactionContent.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/ReactionContent.kt index 02d4164d..c7a86631 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/ReactionContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/ReactionContent.kt @@ -1,4 +1,4 @@ -package im.vector.matrix.android.api.session.room.model.annotation +package im.vector.matrix.android.api.session.room.model.relation import com.squareup.moshi.Json import com.squareup.moshi.JsonClass diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/ReactionInfo.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/ReactionInfo.kt similarity index 56% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/ReactionInfo.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/ReactionInfo.kt index bf573744..3a2f6169 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/ReactionInfo.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/ReactionInfo.kt @@ -1,4 +1,4 @@ -package im.vector.matrix.android.api.session.room.model.annotation +package im.vector.matrix.android.api.session.room.model.relation import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @@ -7,5 +7,7 @@ import com.squareup.moshi.JsonClass data class ReactionInfo( @Json(name = "rel_type") override val type: String?, @Json(name = "event_id") override val eventId: String, - val key: String + val key: String, + //always null for reaction + @Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null ) : RelationContent \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationContent.kt new file mode 100644 index 00000000..3f60af7c --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationContent.kt @@ -0,0 +1,7 @@ +package im.vector.matrix.android.api.session.room.model.relation + +interface RelationContent { + val type: String? + val eventId: String? + val inReplyTo: ReplyToContent? +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/RelationDefaultContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationDefaultContent.kt similarity index 79% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/RelationDefaultContent.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationDefaultContent.kt index 7137e86a..853a3817 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/annotation/RelationDefaultContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationDefaultContent.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package im.vector.matrix.android.api.session.room.model.annotation +package im.vector.matrix.android.api.session.room.model.relation import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @@ -21,5 +21,6 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class RelationDefaultContent( @Json(name = "rel_type") override val type: String?, - @Json(name = "event_id") override val eventId: String? + @Json(name = "event_id") override val eventId: String?, + @Json(name = "m.in_reply_to") override val inReplyTo: ReplyToContent? = null ) : RelationContent diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt new file mode 100644 index 00000000..bc924728 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/RelationService.kt @@ -0,0 +1,94 @@ +/* + * 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.api.session.room.model.relation + +import im.vector.matrix.android.api.session.events.model.Event +import im.vector.matrix.android.api.util.Cancelable + +/** + * In some cases, events may wish to reference other events. + * This could be to form a thread of messages for the user to follow along with, + * or to provide more context as to what a particular event is describing. + * Relation are used to associate new information with an existing event. + * + * Relations are events which have an m.relates_to mixin in their contents, + * and the new information they convey is expressed in their usual event type and content. + * + * Three types of relations are defined, each defining different behaviour when aggregated: + * + * m.annotation - lets you define an event which annotates an existing event. + * When aggregated, groups events together based on key and returns a count. + * (aka SQL's COUNT) These are primarily intended for handling reactions. + * + * m.replace - lets you define an event which replaces an existing event. + * When aggregated, returns the most recent replacement event. (aka SQL's MAX) + * These are primarily intended for handling edits. + * + * 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). + */ +interface RelationService { + + + /** + * Sends a reaction (emoji) to the targetedEvent. + * @param reaction the reaction (preferably emoji) + * @param targetEventId the id of the event being reacted + */ + fun sendReaction(reaction: String, targetEventId: String): Cancelable + + + /** + * Undo a reaction (emoji) to the targetedEvent. + * @param reaction the reaction (preferably emoji) + * @param targetEventId the id of the event being reacted + * @param myUserId used to know if a reaction event was made by the user + */ + fun undoReaction(reaction: String, targetEventId: String, myUserId: String)//: Cancelable + + + /** + * Update a quick reaction (toggle). + * If you have reacted with agree and then you click on disagree, this call will delete(redact) + * the disagree and add the agree + * If you click on a reaction that you already reacted with, it will undo it + * @param reaction the reaction (preferably emoji) + * @param oppositeReaction the opposite reaction(preferably emoji) + * @param targetEventId the id of the event being reacted + * @param myUserId used to know if a reaction event was made by the user + */ + fun updateQuickReaction(reaction: String, oppositeReaction: String, targetEventId: String, myUserId: String) + + + /** + * Edit a text message body. Limited to "m.text" contentType + * @param targetEventId The event to edit + * @param newBodyText The edited body + * @param compatibilityBodyText The text that will appear on clients that don't support yet edition + */ + fun editTextMessage(targetEventId: String, newBodyText: String, newBodyAutoMarkdown: Boolean, compatibilityBodyText: String = "* $newBodyText"): Cancelable + + + /** + * Reply to an event in the timeline (must be in same room) + * 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 + */ + fun replyToMessage(eventReplied: Event, replyText: String): Cancelable? + +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/ReplyToContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/ReplyToContent.kt new file mode 100644 index 00000000..3df8a534 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/relation/ReplyToContent.kt @@ -0,0 +1,25 @@ +/* + * 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.api.session.room.model.relation + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ReplyToContent( + @Json(name = "event_id") val eventId: String +) \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt index 6852931c..875ac75b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt @@ -31,9 +31,18 @@ interface SendService { * Method to send a text message asynchronously. * @param text the text message to send * @param msgType the message type: MessageType.MSGTYPE_TEXT (default) or MessageType.MSGTYPE_EMOTE + * @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 sendTextMessage(text: String, msgType: String = MessageType.MSGTYPE_TEXT): Cancelable + fun sendTextMessage(text: String, msgType: String = MessageType.MSGTYPE_TEXT, autoMarkdown: Boolean = false): Cancelable + + /** + * Method to send a text message with a formatted body. + * @param text the text message to send + * @param formattedText The formatted body using MessageType#FORMAT_MATRIX_HTML + * @return a [Cancelable] + */ + fun sendFormattedTextMessage(text: String,formattedText: String): Cancelable /** * Method to send a media asynchronously. @@ -49,6 +58,11 @@ interface SendService { */ fun sendMedias(attachments: List): Cancelable + /** + * Redacts (delete) the given event. + * @param event The event to redact + * @param reason Optional reason string + */ fun redactEvent(event: Event, reason: String?): Cancelable } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/EventAnnotationsSummaryMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/EventAnnotationsSummaryMapper.kt index 2d1a7da1..8b653b74 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/EventAnnotationsSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/EventAnnotationsSummaryMapper.kt @@ -15,13 +15,15 @@ internal object EventAnnotationsSummaryMapper { it.count, it.addedByMe, it.firstTimestamp, - it.sourceEvents.toList() + it.sourceEvents.toList(), + it.sourceLocalEcho.toList() ) }, editSummary = annotationsSummary.editSummary?.let { EditAggregatedSummary( ContentMapper.map(it.aggregatedContent), it.sourceEvents.toList(), + it.sourceLocalEchoEvents.toList(), it.lastEditTs ) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EditAggregatedSummaryEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EditAggregatedSummaryEntity.kt index 4b2a43e8..f2690c96 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EditAggregatedSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EditAggregatedSummaryEntity.kt @@ -25,6 +25,7 @@ internal open class EditAggregatedSummaryEntity( var aggregatedContent: String? = null, // The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk) var sourceEvents: RealmList = RealmList(), + var sourceLocalEchoEvents: RealmList = RealmList(), var lastEditTs: Long = 0 ) : RealmObject() { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ReactionAggregatedSummaryEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ReactionAggregatedSummaryEntity.kt index 93ec8bd7..4b94313d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ReactionAggregatedSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/ReactionAggregatedSummaryEntity.kt @@ -16,7 +16,9 @@ internal open class ReactionAggregatedSummaryEntity( // The first time this reaction was added (for ordering purpose) var firstTimestamp: Long = 0, // The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk) - var sourceEvents: RealmList = RealmList() + var sourceEvents: RealmList = RealmList(), + // List of transaction ids for local echos + var sourceLocalEcho: RealmList = RealmList() ) : RealmObject() { companion object diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventEntityQueries.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventEntityQueries.kt index c500a880..719407ca 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/EventEntityQueries.kt @@ -48,6 +48,7 @@ internal fun EventEntity.Companion.where(realm: Realm, } } + internal fun EventEntity.Companion.latestEvent(realm: Realm, roomId: String, includedTypes: List = emptyList(), diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixModule.kt index f750c981..693c08f2 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MatrixModule.kt @@ -20,6 +20,7 @@ import android.content.Context import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.util.BackgroundDetectionObserver import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers +import im.vector.matrix.android.internal.util.StringProvider import kotlinx.coroutines.Dispatchers import org.koin.dsl.module.module @@ -39,6 +40,9 @@ class MatrixModule(private val context: Context) { single { TaskExecutor(get()) } + single { + StringProvider(context.resources) + } single { BackgroundDetectionObserver() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt index 6d8084c0..8f8ee454 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt @@ -105,10 +105,6 @@ internal class SessionModule(private val sessionParams: SessionParams) { RoomSummaryUpdater(get(), get(), get()) } - scope(DefaultSession.SCOPE) { - EventRelationsAggregationUpdater(get()) - } - scope(DefaultSession.SCOPE) { DefaultRoomService(get(), get(), get(), get()) as RoomService } @@ -168,9 +164,11 @@ internal class SessionModule(private val sessionParams: SessionParams) { scope(DefaultSession.SCOPE) { val groupSummaryUpdater = GroupSummaryUpdater(get()) - val eventsPruner = EventsPruner(get(), get(), get(), get()) val userEntityUpdater = UserEntityUpdater(get(), get(), get()) - listOf(groupSummaryUpdater, eventsPruner, userEntityUpdater) + val aggregationUpdater = EventRelationsAggregationUpdater(get(), get(), get(), get()) + //Event pruner must be the last one, because it will clear contents + val eventsPruner = EventsPruner(get(), get(), get(), get()) + listOf(groupSummaryUpdater, userEntityUpdater, aggregationUpdater, eventsPruner) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt index 80d89659..fe425a6a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/DefaultRoom.kt @@ -22,7 +22,7 @@ import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.members.MembershipService import im.vector.matrix.android.api.session.room.model.RoomSummary -import im.vector.matrix.android.api.session.room.model.annotation.ReactionService +import im.vector.matrix.android.api.session.room.model.relation.RelationService import im.vector.matrix.android.api.session.room.read.ReadService import im.vector.matrix.android.api.session.room.send.SendService import im.vector.matrix.android.api.session.room.state.StateService @@ -40,14 +40,14 @@ internal class DefaultRoom( private val sendService: SendService, private val stateService: StateService, private val readService: ReadService, - private val reactionService: ReactionService, + private val relationService: RelationService, private val roomMembersService: MembershipService ) : Room, TimelineService by timelineService, SendService by sendService, StateService by stateService, ReadService by readService, - ReactionService by reactionService, + RelationService by relationService, MembershipService by roomMembersService { override val roomSummary: LiveData by lazy { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt new file mode 100644 index 00000000..4ff661e2 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt @@ -0,0 +1,309 @@ +/* + * 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 + +import arrow.core.Try +import com.zhuinden.monarchy.Monarchy +import im.vector.matrix.android.api.session.events.model.* +import im.vector.matrix.android.api.session.room.model.message.MessageContent +import im.vector.matrix.android.api.session.room.model.relation.ReactionContent +import im.vector.matrix.android.api.session.room.send.SendState +import im.vector.matrix.android.internal.database.mapper.ContentMapper +import im.vector.matrix.android.internal.database.mapper.EventMapper +import im.vector.matrix.android.internal.database.model.* +import im.vector.matrix.android.internal.database.query.create +import im.vector.matrix.android.internal.database.query.where +import im.vector.matrix.android.internal.task.Task +import im.vector.matrix.android.internal.util.tryTransactionAsync +import io.realm.Realm +import timber.log.Timber + +internal interface EventRelationsAggregationTask : Task { + + data class Params( + val events: List>, + val userId: String + ) +} + +/** + * Called by EventRelationAggregationUpdater, when new events that can affect relations are inserted in base. + */ +internal class DefaultEventRelationsAggregationTask(private val monarchy: Monarchy) : EventRelationsAggregationTask { + + override fun execute(params: EventRelationsAggregationTask.Params): Try { + return monarchy.tryTransactionAsync { realm -> + update(realm, params.events, params.userId) + } + } + + private fun update(realm: Realm, events: List>, userId: String) { + events.forEach { pair -> + val roomId = pair.first.roomId ?: return@forEach + val event = pair.first + val sendState = pair.second + val isLocalEcho = sendState == SendState.UNSENT + when (event.type) { + EventType.REACTION -> { + //we got a reaction!! + Timber.v("###REACTION in room $roomId") + handleReaction(event, roomId, realm, userId, isLocalEcho) + } + EventType.MESSAGE -> { + if (event.unsignedData?.relations?.annotations != null) { + Timber.v("###REACTION Agreggation in room $roomId for event ${event.eventId}") + handleInitialAggregatedRelations(event, roomId, event.unsignedData.relations.annotations, realm) + } else { + val content: MessageContent? = event.content.toModel() + if (content?.relatesTo?.type == RelationType.REPLACE) { + Timber.v("###REPLACE in room $roomId for event ${event.eventId}") + //A replace! + handleReplace(realm, event, content, roomId, isLocalEcho) + } + } + + } + EventType.REDACTION -> { + val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() } + ?: return + when (eventToPrune.type) { + EventType.MESSAGE -> { + Timber.d("REDACTION for message ${eventToPrune.eventId}") + val unsignedData = EventMapper.map(eventToPrune).unsignedData + ?: UnsignedData(null, null) + + //was this event a m.replace + val contentModel = ContentMapper.map(eventToPrune.content)?.toModel() + if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) { + handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm) + } + + } + EventType.REACTION -> { + handleReactionRedact(eventToPrune, realm, userId) + } + } + } + } + } + } + + private fun handleReplace(realm: Realm, event: Event, content: MessageContent, roomId: String, isLocalEcho: Boolean) { + val eventId = event.eventId ?: return + val targetEventId = content.relatesTo?.eventId ?: return + val newContent = content.newContent ?: return + //ok, this is a replace + var existing = EventAnnotationsSummaryEntity.where(realm, targetEventId).findFirst() + if (existing == null) { + Timber.v("###REPLACE creating no relation summary for ${targetEventId}") + existing = EventAnnotationsSummaryEntity.create(realm, targetEventId) + existing.roomId = roomId + } + + //we have it + val existingSummary = existing.editSummary + if (existingSummary == null) { + Timber.v("###REPLACE no edit summary for ${targetEventId}, creating one (localEcho:$isLocalEcho)") + //create the edit summary + val editSummary = realm.createObject(EditAggregatedSummaryEntity::class.java) + editSummary.lastEditTs = event.originServerTs ?: System.currentTimeMillis() + editSummary.aggregatedContent = ContentMapper.map(newContent) + if (isLocalEcho) { + editSummary.sourceLocalEchoEvents.add(eventId) + } else { + editSummary.sourceEvents.add(eventId) + } + + existing.editSummary = editSummary + } else { + if (existingSummary.sourceEvents.contains(eventId)) { + //ignore this event, we already know it (??) + Timber.v("###REPLACE ignoring event for summary, it's known ${eventId}") + return + } + val txId = event.unsignedData?.transactionId + //is it a remote echo? + if (!isLocalEcho && existingSummary.sourceLocalEchoEvents.contains(txId)) { + //ok it has already been managed + Timber.v("###REPLACE Receiving remote echo of edit (edit already done)") + existingSummary.sourceLocalEchoEvents.remove(txId) + existingSummary.sourceEvents.add(event.eventId) + } else if (event.originServerTs ?: 0 > existingSummary.lastEditTs) { + Timber.v("###REPLACE Computing aggregated edit summary (isLocalEcho:$isLocalEcho)") + existingSummary.lastEditTs = event.originServerTs ?: System.currentTimeMillis() + existingSummary.aggregatedContent = ContentMapper.map(newContent) + existingSummary.sourceEvents.add(eventId) + } else { + //ignore this event for the summary + Timber.v("###REPLACE ignoring event for summary, it's to old ${eventId}") + } + } + + } + + private fun handleInitialAggregatedRelations(event: Event, roomId: String, aggregation: AggregatedAnnotation, realm: Realm) { + aggregation.chunk?.forEach { + if (it.type == EventType.REACTION) { + val eventId = event.eventId ?: "" + val existing = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst() + if (existing == null) { + val eventSummary = EventAnnotationsSummaryEntity.create(realm, eventId) + eventSummary.roomId = roomId + val sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java) + sum.key = it.key + sum.firstTimestamp = event.originServerTs ?: 0 //TODO how to maintain order? + sum.count = it.count + eventSummary.reactionsSummary.add(sum) + } else { + //TODO how to handle that + } + } + } + } + + private fun handleReaction(event: Event, roomId: String, realm: Realm, userId: String, isLocalEcho: Boolean) { + event.content.toModel()?.let { content -> + //rel_type must be m.annotation + if (RelationType.ANNOTATION == content.relatesTo?.type) { + val reaction = content.relatesTo.key + val eventId = content.relatesTo.eventId + val eventSummary = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst() + ?: EventAnnotationsSummaryEntity.create(realm, eventId).apply { this.roomId = roomId } + + var sum = eventSummary.reactionsSummary.find { it.key == reaction } + val txId = event.unsignedData?.transactionId + if (isLocalEcho && txId.isNullOrBlank()) { + Timber.w("Received a local echo with no transaction ID") + } + if (sum == null) { + sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java) + sum.key = reaction + sum.firstTimestamp = event.originServerTs ?: 0 + if (isLocalEcho) { + Timber.v("Adding local echo reaction $reaction") + sum.sourceLocalEcho.add(txId) + sum.count = 1 + } else { + Timber.v("Adding synced reaction $reaction") + sum.count = 1 + sum.sourceEvents.add(event.eventId) + } + sum.addedByMe = sum.addedByMe || (userId == event.sender) + eventSummary.reactionsSummary.add(sum) + } else { + //is this a known event (is possible? pagination?) + if (!sum.sourceEvents.contains(eventId)) { + + //check if it's not the sync of a local echo + if (!isLocalEcho && sum.sourceLocalEcho.contains(txId)) { + //ok it has already been counted, just sync the list, do not touch count + Timber.v("Ignoring synced of local echo for reaction $reaction") + sum.sourceLocalEcho.remove(txId) + sum.sourceEvents.add(event.eventId) + } else { + sum.count += 1 + if (isLocalEcho) { + Timber.v("Adding local echo reaction $reaction") + sum.sourceLocalEcho.add(txId) + } else { + Timber.v("Adding synced reaction $reaction") + sum.sourceEvents.add(event.eventId) + } + + sum.addedByMe = sum.addedByMe || (userId == event.sender) + } + + } + } + + } + } + } + + /** + * Called when an event is deleted + */ + private fun handleRedactionOfReplace(redacted: EventEntity, relatedEventId: String, realm: Realm) { + Timber.d("Handle redaction of m.replace") + val eventSummary = EventAnnotationsSummaryEntity.where(realm, relatedEventId).findFirst() + if (eventSummary == null) { + Timber.w("Redaction of a replace targeting an unknown event $relatedEventId") + return + } + val sourceEvents = eventSummary.editSummary?.sourceEvents + val sourceToDiscard = sourceEvents?.indexOf(redacted.eventId) + if (sourceToDiscard == null) { + Timber.w("Redaction of a replace that was not known in aggregation $sourceToDiscard") + return + } + //Need to remove this event from the redaction list and compute new aggregation state + sourceEvents.removeAt(sourceToDiscard) + val previousEdit = sourceEvents.mapNotNull { EventEntity.where(realm, it).findFirst() }.sortedBy { it.originServerTs }.lastOrNull() + if (previousEdit == null) { + //revert to original + eventSummary.editSummary?.deleteFromRealm() + } else { + //I have the last event + ContentMapper.map(previousEdit.content)?.toModel()?.newContent?.let { newContent -> + eventSummary.editSummary?.lastEditTs = previousEdit.originServerTs + ?: System.currentTimeMillis() + eventSummary.editSummary?.aggregatedContent = ContentMapper.map(newContent) + } ?: run { + Timber.e("Failed to udate edited summary") + //TODO how to reccover that + } + + } + } + + fun handleReactionRedact(eventToPrune: EventEntity, realm: Realm, userId: String) { + Timber.v("REDACTION of reaction ${eventToPrune.eventId}") + //delete a reaction, need to update the annotation summary if any + val reactionContent: ReactionContent = EventMapper.map(eventToPrune).content.toModel() + ?: return + val eventThatWasReacted = reactionContent.relatesTo?.eventId ?: return + + val reactionKey = reactionContent.relatesTo.key + Timber.v("REMOVE reaction for key $reactionKey") + val summary = EventAnnotationsSummaryEntity.where(realm, eventThatWasReacted).findFirst() + if (summary != null) { + summary.reactionsSummary.where() + .equalTo(ReactionAggregatedSummaryEntityFields.KEY, reactionKey) + .findFirst()?.let { aggregation -> + Timber.v("Find summary for key with ${aggregation.sourceEvents.size} known reactions (count:${aggregation.count})") + Timber.v("Known reactions ${aggregation.sourceEvents.joinToString(",")}") + if (aggregation.sourceEvents.contains(eventToPrune.eventId)) { + Timber.v("REMOVE reaction for key $reactionKey") + aggregation.sourceEvents.remove(eventToPrune.eventId) + Timber.v("Known reactions after ${aggregation.sourceEvents.joinToString(",")}") + aggregation.count = aggregation.count - 1 + if (eventToPrune.sender == userId) { + //Was it a redact on my reaction? + aggregation.addedByMe = false + } + if (aggregation.count == 0) { + //delete! + aggregation.deleteFromRealm() + } + } else { + Timber.e("## Cannot remove summary from count, corresponding reaction ${eventToPrune.eventId} is not known") + } + } + } else { + Timber.e("## Cannot find summary for key $reactionKey") + } + } +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationUpdater.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationUpdater.kt index 08698c08..0b29064c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationUpdater.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationUpdater.kt @@ -15,16 +15,14 @@ */ package im.vector.matrix.android.internal.session.room +import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.auth.data.Credentials -import im.vector.matrix.android.api.session.events.model.* -import im.vector.matrix.android.api.session.room.model.annotation.ReactionContent -import im.vector.matrix.android.api.session.room.model.message.MessageContent -import im.vector.matrix.android.internal.database.mapper.ContentMapper -import im.vector.matrix.android.internal.database.mapper.EventMapper -import im.vector.matrix.android.internal.database.model.* -import im.vector.matrix.android.internal.database.query.create +import im.vector.matrix.android.internal.database.RealmLiveEntityObserver +import im.vector.matrix.android.internal.database.mapper.asDomain +import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.query.where -import io.realm.Realm +import im.vector.matrix.android.internal.task.TaskExecutor +import im.vector.matrix.android.internal.task.configureWith import timber.log.Timber /** @@ -32,198 +30,34 @@ import timber.log.Timber * For reactions will build a EventAnnotationsSummaryEntity, ans for edits a EditAggregatedSummaryEntity. * The summaries can then be extracted and added (as a decoration) to a TimelineEvent for final display. */ -internal class EventRelationsAggregationUpdater(private val credentials: Credentials) { +internal class EventRelationsAggregationUpdater(monarchy: Monarchy, + private val credentials: Credentials, + private val task: EventRelationsAggregationTask, + private val taskExecutor: TaskExecutor) : + RealmLiveEntityObserver(monarchy) { - fun update(realm: Realm, roomId: String, events: List?) { - events?.forEach { event -> - when (event.type) { - EventType.REACTION -> { - //we got a reaction!! - Timber.v("###REACTION in room $roomId") - handleReaction(event, roomId, realm) - } - EventType.MESSAGE -> { - if (event.unsignedData?.relations?.annotations != null) { - Timber.v("###REACTION Agreggation in room $roomId for event ${event.eventId}") - handleInitialAggregatedRelations(event, roomId, event.unsignedData.relations.annotations, realm) - } else { - val content: MessageContent? = event.content.toModel() - if (content?.relatesTo?.type == RelationType.REPLACE) { - Timber.v("###REPLACE in room $roomId for event ${event.eventId}") - //A replace! - handleReplace(event, content, roomId, realm) - } - } - } - } - } + override val query = Monarchy.Query { + EventEntity.where(it) + //mmm why is this query not working? +// EventEntity.byTypes(it, listOf( +// EventType.REDACTION, EventType.MESSAGE, EventType.REDACTION) +// ) } - private fun handleReplace(event: Event, content: MessageContent, roomId: String, realm: Realm) { - val eventId = event.eventId ?: return - val targetEventId = content.relatesTo?.eventId ?: return - val newContent = content.newContent ?: return - //ok, this is a replace - var existing = EventAnnotationsSummaryEntity.where(realm, targetEventId).findFirst() - if (existing == null) { - Timber.v("###REPLACE creating no relation summary for ${targetEventId}") - existing = EventAnnotationsSummaryEntity.create(realm, targetEventId) - existing.roomId = roomId - } + override fun processChanges(inserted: List, updated: List, deleted: List) { + Timber.v("EventRelationsAggregationUpdater called with ${inserted.size} insertions") + val inserted = inserted + .mapNotNull { it.asDomain() to it.sendState } - //we have it - val existingSummary = existing.editSummary - if (existingSummary == null) { - Timber.v("###REPLACE no edit summary for ${targetEventId}, creating one") - //create the edit summary - val editSummary = realm.createObject(EditAggregatedSummaryEntity::class.java) - editSummary.lastEditTs = event.originServerTs ?: System.currentTimeMillis() - editSummary.aggregatedContent = ContentMapper.map(newContent) - editSummary.sourceEvents.add(eventId) + val params = EventRelationsAggregationTask.Params( + inserted, + credentials.userId + ) - existing.editSummary = editSummary - } else { - if (existingSummary.sourceEvents.contains(eventId)) { - //ignore this event, we already know it (??) - Timber.v("###REPLACE ignoring event for summary, it's known ${eventId}") - return - } - //This message has already been edited - if (event.originServerTs ?: 0 > existingSummary.lastEditTs ?: 0) { - Timber.v("###REPLACE Computing aggregated edit summary") - existingSummary.lastEditTs = event.originServerTs ?: System.currentTimeMillis() - existingSummary.aggregatedContent = ContentMapper.map(newContent) - existingSummary.sourceEvents.add(eventId) - } else { - //ignore this event for the summary - Timber.v("###REPLACE ignoring event for summary, it's to old ${eventId}") - } - } + task.configureWith(params) + .executeBy(taskExecutor) } - private fun handleInitialAggregatedRelations(event: Event, roomId: String, aggregation: AggregatedAnnotation, realm: Realm) { - aggregation.chunk?.forEach { - if (it.type == EventType.REACTION) { - val eventId = event.eventId ?: "" - val existing = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst() - if (existing == null) { - val eventSummary = EventAnnotationsSummaryEntity.create(realm, eventId) - eventSummary.roomId = roomId - val sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java) - sum.key = it.key - sum.firstTimestamp = event.originServerTs ?: 0 //TODO how to maintain order? - sum.count = it.count - eventSummary.reactionsSummary.add(sum) - } else { - //TODO how to handle that - } - } - } - } +} - fun handleReaction(event: Event, roomId: String, realm: Realm) { - event.content.toModel()?.let { content -> - //rel_type must be m.annotation - if (RelationType.ANNOTATION == content.relatesTo?.type) { - val reaction = content.relatesTo.key - val eventId = content.relatesTo.eventId - val eventSummary = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst() - ?: EventAnnotationsSummaryEntity.create(realm, eventId).apply { this.roomId = roomId } - - 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 - sum.count = 1 - sum.sourceEvents.add(event.eventId) - sum.addedByMe = sum.addedByMe || (credentials.userId == event.sender) - eventSummary.reactionsSummary.add(sum) - } else { - //is this a known event (is possible? pagination?) - if (!sum.sourceEvents.contains(eventId)) { - sum.count += 1 - sum.sourceEvents.add(event.eventId) - sum.addedByMe = sum.addedByMe || (credentials.userId == event.sender) - } - } - - } - } - } - - /** - * Called when an event is deleted - */ - fun handleRedactionOfReplace(redacted: EventEntity, relatedEventId: String, realm: Realm) { - Timber.d("Handle redaction of m.replace") - val eventSummary = EventAnnotationsSummaryEntity.where(realm, relatedEventId).findFirst() - if (eventSummary == null) { - Timber.w("Redaction of a replace targeting an unknown event $relatedEventId") - return - } - val sourceEvents = eventSummary.editSummary?.sourceEvents - val sourceToDiscard = sourceEvents?.indexOf(redacted.eventId) - if (sourceToDiscard == null) { - Timber.w("Redaction of a replace that was not known in aggregation $sourceToDiscard") - return - } - //Need to remove this event from the redaction list and compute new aggregation state - sourceEvents.removeAt(sourceToDiscard) - val previousEdit = sourceEvents.mapNotNull { EventEntity.where(realm, it).findFirst() }.sortedBy { it.originServerTs }.lastOrNull() - if (previousEdit == null) { - //revert to original - eventSummary.editSummary?.deleteFromRealm() - } else { - //I have the last event - ContentMapper.map(previousEdit.content)?.toModel()?.newContent?.let { newContent -> - eventSummary.editSummary?.lastEditTs = previousEdit.originServerTs - ?: System.currentTimeMillis() - eventSummary.editSummary?.aggregatedContent = ContentMapper.map(newContent) - } ?: run { - Timber.e("Failed to udate edited summary") - //TODO how to reccover that - } - - } - } - - fun handleReactionRedact(eventToPrune: EventEntity, realm: Realm, userId: String) { - Timber.d("REDACTION of reaction ${eventToPrune.eventId}") - //delete a reaction, need to update the annotation summary if any - val reactionContent: ReactionContent = EventMapper.map(eventToPrune).content.toModel() - ?: return - val eventThatWasReacted = reactionContent.relatesTo?.eventId ?: return - - val reactionkey = reactionContent.relatesTo.key - Timber.d("REMOVE reaction for key $reactionkey") - val summary = EventAnnotationsSummaryEntity.where(realm, eventThatWasReacted).findFirst() - if (summary != null) { - summary.reactionsSummary.where() - .equalTo(ReactionAggregatedSummaryEntityFields.KEY, reactionkey) - .findFirst()?.let { summary -> - Timber.d("Find summary for key with ${summary.sourceEvents.size} known reactions (count:${summary.count})") - Timber.d("Known reactions ${summary.sourceEvents.joinToString(",")}") - if (summary.sourceEvents.contains(eventToPrune.eventId)) { - Timber.d("REMOVE reaction for key $reactionkey") - summary.sourceEvents.remove(eventToPrune.eventId) - Timber.d("Known reactions after ${summary.sourceEvents.joinToString(",")}") - summary.count = summary.count - 1 - if (eventToPrune.sender == userId) { - //Was it a redact on my reaction? - summary.addedByMe = false - } - if (summary.count == 0) { - //delete! - summary.deleteFromRealm() - } - } else { - Timber.e("## Cannot remove summary from count, corresponding reaction ${eventToPrune.eventId} is not known") - } - } - } else { - Timber.e("## Cannot find summary for key $reactionkey") - } - } -} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt index c59e3f8d..e8029c09 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt @@ -18,10 +18,10 @@ package im.vector.matrix.android.internal.session.room import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.session.room.Room +import im.vector.matrix.android.internal.session.room.relation.DefaultRelationService +import im.vector.matrix.android.internal.session.room.relation.FindReactionEventForUndoTask +import im.vector.matrix.android.internal.session.room.relation.UpdateQuickReactionTask import im.vector.matrix.android.internal.session.room.membership.DefaultMembershipService -import im.vector.matrix.android.internal.session.room.annotation.FindReactionEventForUndoTask -import im.vector.matrix.android.internal.session.room.annotation.DefaultReactionService -import im.vector.matrix.android.internal.session.room.annotation.UpdateQuickReactionTask import im.vector.matrix.android.internal.session.room.membership.LoadRoomMembersTask import im.vector.matrix.android.internal.session.room.membership.SenderRoomMemberExtractor import im.vector.matrix.android.internal.session.room.membership.joining.InviteTask @@ -58,7 +58,7 @@ internal class RoomFactory(private val monarchy: Monarchy, val timelineEventFactory = TimelineEventFactory(roomMemberExtractor, EventRelationExtractor()) val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, timelineEventFactory, contextOfEventTask, paginationTask) val sendService = DefaultSendService(roomId, eventFactory, monarchy) - val reactionService = DefaultReactionService(roomId, eventFactory, findReactionEventForUndoTask, updateQuickReactionTask, taskExecutor) + val reactionService = DefaultRelationService(roomId, eventFactory, findReactionEventForUndoTask, updateQuickReactionTask, monarchy, taskExecutor) val stateService = DefaultStateService(roomId, taskExecutor, sendStateTask) val roomMembersService = DefaultMembershipService(roomId, monarchy, taskExecutor, loadRoomMembersTask, inviteTask, joinRoomTask, leaveRoomTask) val readService = DefaultReadService(roomId, monarchy, taskExecutor, setReadMarkersTask) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt index 952909ef..7f47f576 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt @@ -17,10 +17,6 @@ package im.vector.matrix.android.internal.session.room import im.vector.matrix.android.internal.session.DefaultSession -import im.vector.matrix.android.internal.session.room.annotation.DefaultFindReactionEventForUndoTask -import im.vector.matrix.android.internal.session.room.annotation.DefaultUpdateQuickReactionTask -import im.vector.matrix.android.internal.session.room.annotation.FindReactionEventForUndoTask -import im.vector.matrix.android.internal.session.room.annotation.UpdateQuickReactionTask import im.vector.matrix.android.internal.session.room.create.CreateRoomTask import im.vector.matrix.android.internal.session.room.create.DefaultCreateRoomTask import im.vector.matrix.android.internal.session.room.membership.DefaultLoadRoomMembersTask @@ -35,6 +31,10 @@ import im.vector.matrix.android.internal.session.room.prune.DefaultPruneEventTas import im.vector.matrix.android.internal.session.room.prune.PruneEventTask import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask import im.vector.matrix.android.internal.session.room.read.SetReadMarkersTask +import im.vector.matrix.android.internal.session.room.relation.DefaultFindReactionEventForUndoTask +import im.vector.matrix.android.internal.session.room.relation.DefaultUpdateQuickReactionTask +import im.vector.matrix.android.internal.session.room.relation.FindReactionEventForUndoTask +import im.vector.matrix.android.internal.session.room.relation.UpdateQuickReactionTask import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory import im.vector.matrix.android.internal.session.room.state.DefaultSendStateTask import im.vector.matrix.android.internal.session.room.state.SendStateTask @@ -57,7 +57,7 @@ class RoomModule { } scope(DefaultSession.SCOPE) { - TokenChunkEventPersistor(get(), get()) + TokenChunkEventPersistor(get()) } scope(DefaultSession.SCOPE) { @@ -73,7 +73,7 @@ class RoomModule { } scope(DefaultSession.SCOPE) { - LocalEchoEventFactory(get()) + LocalEchoEventFactory(get(), get()) } scope(DefaultSession.SCOPE) { @@ -109,7 +109,11 @@ class RoomModule { } scope(DefaultSession.SCOPE) { - DefaultPruneEventTask(get(),get()) as PruneEventTask + DefaultPruneEventTask(get()) as PruneEventTask + } + + scope(DefaultSession.SCOPE) { + DefaultEventRelationsAggregationTask(get()) as EventRelationsAggregationTask } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/annotation/DefaultReactionService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/annotation/DefaultReactionService.kt deleted file mode 100644 index dbef5461..00000000 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/annotation/DefaultReactionService.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * 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.annotation - -import androidx.work.* -import im.vector.matrix.android.api.MatrixCallback -import im.vector.matrix.android.api.session.events.model.Event -import im.vector.matrix.android.api.session.room.model.annotation.ReactionService -import im.vector.matrix.android.api.util.Cancelable -import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory -import im.vector.matrix.android.internal.session.room.send.RedactEventWorker -import im.vector.matrix.android.internal.session.room.send.SendEventWorker -import im.vector.matrix.android.internal.task.TaskExecutor -import im.vector.matrix.android.internal.task.configureWith -import im.vector.matrix.android.internal.util.CancelableWork -import im.vector.matrix.android.internal.util.WorkerParamsFactory -import java.util.concurrent.TimeUnit - -private const val REACTION_WORK = "REACTION_WORK" -private const val BACKOFF_DELAY = 10_000L - -private val WORK_CONSTRAINTS = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - -internal class DefaultReactionService(private val roomId: String, - private val eventFactory: LocalEchoEventFactory, - private val findReactionEventForUndoTask: FindReactionEventForUndoTask, - private val updateQuickReactionTask: UpdateQuickReactionTask, - private val taskExecutor: TaskExecutor) - : ReactionService { - - - override fun sendReaction(reaction: String, targetEventId: String): Cancelable { - val event = eventFactory.createReactionEvent(roomId, targetEventId, reaction) -// .also { -// //saveLocalEcho(it) -// } - val sendRelationWork = createSendRelationWork(event) - WorkManager.getInstance() - .beginUniqueWork(buildWorkIdentifier(REACTION_WORK), ExistingWorkPolicy.APPEND, sendRelationWork) - .enqueue() - return CancelableWork(sendRelationWork.id) - } - - - private fun createSendRelationWork(event: Event): OneTimeWorkRequest { - //TODO use the new API to send relation (for now use regular send) - val sendContentWorkerParams = SendEventWorker.Params( - roomId, event) - val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams) - - return OneTimeWorkRequestBuilder() - .setConstraints(WORK_CONSTRAINTS) - .setInputData(sendWorkData) - .setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS) - .build() - } - - override fun undoReaction(reaction: String, targetEventId: String, myUserId: String)/*: Cancelable*/ { - - val params = FindReactionEventForUndoTask.Params( - roomId, - targetEventId, - reaction, - myUserId - ) - findReactionEventForUndoTask.configureWith(params) - .enableRetry() - .dispatchTo(object : MatrixCallback { - override fun onSuccess(data: FindReactionEventForUndoTask.Result) { - data.redactEventId?.let { toRedact -> - val redactWork = createRedactEventWork(toRedact, null) - WorkManager.getInstance() - .beginUniqueWork(buildWorkIdentifier(REACTION_WORK), ExistingWorkPolicy.APPEND, redactWork) - .enqueue() - } - } - }) - .executeBy(taskExecutor) - - } - - - override fun updateQuickReaction(reaction: String, oppositeReaction: String, targetEventId: String, myUserId: String) { - - val params = UpdateQuickReactionTask.Params( - roomId, - targetEventId, - reaction, - oppositeReaction, - myUserId - ) - - updateQuickReactionTask.configureWith(params) - .dispatchTo(object : MatrixCallback { - override fun onSuccess(data: UpdateQuickReactionTask.Result) { - data.reactionToAdd?.also { sendReaction(it, targetEventId) } - data.reactionToRedact.forEach { - val redactWork = createRedactEventWork(it, null) - WorkManager.getInstance() - .beginUniqueWork(buildWorkIdentifier(REACTION_WORK), ExistingWorkPolicy.APPEND, redactWork) - .enqueue() - } - } - }) - .executeBy(taskExecutor) - } - - private fun buildWorkIdentifier(identifier: String): String { - return "${roomId}_$identifier" - } - -// private fun saveLocalEcho(event: Event) { -// monarchy.tryTransactionAsync { realm -> -// val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst() -// ?: return@tryTransactionAsync -// val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId = roomId) -// ?: return@tryTransactionAsync -// -// roomEntity.addSendingEvent(event, liveChunk.forwardsStateIndex ?: 0) -// } -// } - - //TODO duplicate with send service? - private fun createRedactEventWork(eventId: String, reason: String?): OneTimeWorkRequest { - - //TODO create local echo of m.room.redaction event? - - val sendContentWorkerParams = RedactEventWorker.Params( - roomId, eventId, reason) - val redactWorkData = WorkerParamsFactory.toData(sendContentWorkerParams) - - return OneTimeWorkRequestBuilder() - .setConstraints(WORK_CONSTRAINTS) - .setInputData(redactWorkData) - .setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS) - .build() - } -} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/EventsPruner.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/EventsPruner.kt index cb7a3818..4d3bc6fd 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/EventsPruner.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/EventsPruner.kt @@ -25,8 +25,12 @@ import im.vector.matrix.android.internal.database.model.EventEntity 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 timber.log.Timber - +/** + * Listens to the database for the insertion of any redaction event. + * As it will actually delete the content, it should be called last in the list of listener. + */ internal class EventsPruner(monarchy: Monarchy, private val credentials: Credentials, private val pruneEventTask: PruneEventTask, @@ -36,6 +40,7 @@ internal class EventsPruner(monarchy: Monarchy, override val query = Monarchy.Query { EventEntity.where(it, type = EventType.REDACTION) } override fun processChanges(inserted: List, updated: List, deleted: List) { + Timber.v("Event pruner called with ${inserted.size} insertions") val redactionEvents = inserted .mapNotNull { it.asDomain() } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/PruneEventTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/PruneEventTask.kt index 44c62a09..a4025978 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/PruneEventTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/prune/PruneEventTask.kt @@ -17,14 +17,15 @@ package im.vector.matrix.android.internal.session.room.prune import arrow.core.Try import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.api.session.events.model.* -import im.vector.matrix.android.api.session.room.model.message.MessageContent +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.UnsignedData +import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.internal.database.mapper.ContentMapper import im.vector.matrix.android.internal.database.mapper.EventMapper import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.di.MoshiProvider -import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater import im.vector.matrix.android.internal.task.Task import im.vector.matrix.android.internal.util.tryTransactionSync import io.realm.Realm @@ -41,8 +42,7 @@ internal interface PruneEventTask : Task { } internal class DefaultPruneEventTask( - private val monarchy: Monarchy, - private val eventRelationsAggregationUpdater: EventRelationsAggregationUpdater) : PruneEventTask { + private val monarchy: Monarchy) : PruneEventTask { override fun execute(params: PruneEventTask.Params): Try { return monarchy.tryTransactionSync { realm -> @@ -57,6 +57,12 @@ internal class DefaultPruneEventTask( return } + val redactionEventEntity = EventEntity.where(realm, eventId = redactionEvent.eventId + ?: "").findFirst() + ?: return + val isLocalEcho = redactionEventEntity.sendState == SendState.UNSENT + Timber.v("Redact event for ${redactionEvent.redacts} localEcho=$isLocalEcho") + val eventToPrune = EventEntity.where(realm, eventId = redactionEvent.redacts).findFirst() ?: return @@ -72,19 +78,19 @@ internal class DefaultPruneEventTask( ?: UnsignedData(null, null) //was this event a m.replace - val contentModel = ContentMapper.map(eventToPrune.content)?.toModel() - if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) { - eventRelationsAggregationUpdater.handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm) - } +// val contentModel = ContentMapper.map(eventToPrune.content)?.toModel() +// if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) { +// eventRelationsAggregationUpdater.handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm) +// } val modified = unsignedData.copy(redactedEvent = redactionEvent) eventToPrune.content = ContentMapper.map(emptyMap()) eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified) } - EventType.REACTION -> { - eventRelationsAggregationUpdater.handleReactionRedact(eventToPrune, realm, userId) - } +// EventType.REACTION -> { +// eventRelationsAggregationUpdater.handleReactionRedact(eventToPrune, realm, userId) +// } } } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt new file mode 100644 index 00000000..6d6e4763 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/DefaultRelationService.kt @@ -0,0 +1,188 @@ +/* + * 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.relation + +import androidx.work.OneTimeWorkRequest +import com.zhuinden.monarchy.Monarchy +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.events.model.Event +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.util.Cancelable +import im.vector.matrix.android.internal.database.helper.addSendingEvent +import im.vector.matrix.android.internal.database.model.ChunkEntity +import im.vector.matrix.android.internal.database.model.RoomEntity +import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom +import im.vector.matrix.android.internal.database.query.where +import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory +import im.vector.matrix.android.internal.session.room.send.RedactEventWorker +import im.vector.matrix.android.internal.session.room.send.SendEventWorker +import im.vector.matrix.android.internal.session.room.timeline.TimelineSendEventWorkCommon +import im.vector.matrix.android.internal.task.TaskExecutor +import im.vector.matrix.android.internal.task.configureWith +import im.vector.matrix.android.internal.util.CancelableWork +import im.vector.matrix.android.internal.util.WorkerParamsFactory +import im.vector.matrix.android.internal.util.tryTransactionAsync +import timber.log.Timber + + +internal class DefaultRelationService(private val roomId: String, + private val eventFactory: LocalEchoEventFactory, + private val findReactionEventForUndoTask: FindReactionEventForUndoTask, + private val updateQuickReactionTask: UpdateQuickReactionTask, + private val monarchy: Monarchy, + private val taskExecutor: TaskExecutor) + : RelationService { + + + override fun sendReaction(reaction: String, targetEventId: String): Cancelable { + val event = eventFactory.createReactionEvent(roomId, targetEventId, reaction) + .also { + saveLocalEcho(it) + } + val sendRelationWork = createSendRelationWork(event) + TimelineSendEventWorkCommon.postWork(roomId, sendRelationWork) + return CancelableWork(sendRelationWork.id) + } + + + private fun createSendRelationWork(event: Event): OneTimeWorkRequest { + val sendContentWorkerParams = SendEventWorker.Params( + roomId, event) + val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams) + + return TimelineSendEventWorkCommon.createWork(sendWorkData) + + } + + override fun undoReaction(reaction: String, targetEventId: String, myUserId: String)/*: Cancelable*/ { + + val params = FindReactionEventForUndoTask.Params( + roomId, + targetEventId, + reaction, + myUserId + ) + findReactionEventForUndoTask.configureWith(params) + .enableRetry() + .dispatchTo(object : MatrixCallback { + override fun onSuccess(data: FindReactionEventForUndoTask.Result) { + if (data.redactEventId == null) { + Timber.w("Cannot find reaction to undo (not yet synced?)") + //TODO? + } + data.redactEventId?.let { toRedact -> + + val redactEvent = eventFactory.createRedactEvent(roomId, toRedact, null).also { + saveLocalEcho(it) + } + val redactWork = createRedactEventWork(redactEvent, toRedact, null) + + TimelineSendEventWorkCommon.postWork(roomId, redactWork) + + } + } + }) + .executeBy(taskExecutor) + + } + + + override fun updateQuickReaction(reaction: String, oppositeReaction: String, targetEventId: String, myUserId: String) { + + val params = UpdateQuickReactionTask.Params( + roomId, + targetEventId, + reaction, + oppositeReaction, + myUserId + ) + + updateQuickReactionTask.configureWith(params) + .dispatchTo(object : MatrixCallback { + override fun onSuccess(data: UpdateQuickReactionTask.Result) { + data.reactionToAdd?.also { sendReaction(it, targetEventId) } + data.reactionToRedact.forEach { + val redactEvent = eventFactory.createRedactEvent(roomId, it, null).also { + saveLocalEcho(it) + } + val redactWork = createRedactEventWork(redactEvent, it, null) + TimelineSendEventWorkCommon.postWork(roomId, redactWork) + } + } + }) + .executeBy(taskExecutor) + } + + private fun buildWorkIdentifier(identifier: String): String { + return "${roomId}_$identifier" + } + + //TODO duplicate with send service? + private fun createRedactEventWork(localEvent: Event, eventId: String, reason: String?): OneTimeWorkRequest { + + val sendContentWorkerParams = RedactEventWorker.Params(localEvent.eventId!!, + roomId, eventId, reason) + val redactWorkData = WorkerParamsFactory.toData(sendContentWorkerParams) + return TimelineSendEventWorkCommon.createWork(redactWorkData) + } + + override fun editTextMessage(targetEventId: String, newBodyText: String, newBodyAutoMarkdown: Boolean, compatibilityBodyText: String): Cancelable { + val event = eventFactory.createReplaceTextEvent(roomId, targetEventId, newBodyText, newBodyAutoMarkdown, MessageType.MSGTYPE_TEXT, compatibilityBodyText).also { + saveLocalEcho(it) + } + val sendContentWorkerParams = SendEventWorker.Params(roomId, event) + val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams) + + //TODO use relation API? + + val workRequest = TimelineSendEventWorkCommon.createWork(sendWorkData) + TimelineSendEventWorkCommon.postWork(roomId, workRequest) + return CancelableWork(workRequest.id) + + } + + + override fun replyToMessage(eventReplied: Event, replyText: String): Cancelable? { + val event = eventFactory.createReplyTextEvent(roomId, eventReplied, replyText)?.also { + saveLocalEcho(it) + } ?: return null + val sendContentWorkerParams = SendEventWorker.Params(roomId, event) + val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams) + + + val workRequest = TimelineSendEventWorkCommon.createWork(sendWorkData) + TimelineSendEventWorkCommon.postWork(roomId, workRequest) + return CancelableWork(workRequest.id) + } + + /** + * 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. + * The sendingTimelineEvents is checked on new sync and will remove the local echo if an event with + * the same transaction id is received (in unsigned data) + */ + private fun saveLocalEcho(event: Event) { + monarchy.tryTransactionAsync { realm -> + val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst() + ?: return@tryTransactionAsync + val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId = roomId) + ?: return@tryTransactionAsync + + roomEntity.addSendingEvent(event, liveChunk.forwardsStateIndex ?: 0) + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/annotation/FindReactionEventForUndoTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/FindReactionEventForUndoTask.kt similarity index 97% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/annotation/FindReactionEventForUndoTask.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/FindReactionEventForUndoTask.kt index 441b289e..0f7e25ca 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/annotation/FindReactionEventForUndoTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/FindReactionEventForUndoTask.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package im.vector.matrix.android.internal.session.room.annotation +package im.vector.matrix.android.internal.session.room.relation import arrow.core.Try import com.zhuinden.monarchy.Monarchy diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/annotation/SendRelationWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/SendRelationWorker.kt similarity index 86% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/annotation/SendRelationWorker.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/SendRelationWorker.kt index e262dcaf..41184aaf 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/annotation/SendRelationWorker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/SendRelationWorker.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package im.vector.matrix.android.internal.session.room.annotation +package im.vector.matrix.android.internal.session.room.relation import android.content.Context import androidx.work.Worker @@ -22,8 +22,8 @@ import com.squareup.moshi.JsonClass import im.vector.matrix.android.api.failure.Failure import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.toModel -import im.vector.matrix.android.api.session.room.model.annotation.ReactionContent -import im.vector.matrix.android.api.session.room.model.annotation.ReactionInfo +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.internal.di.MatrixKoinComponent import im.vector.matrix.android.internal.network.executeRequest import im.vector.matrix.android.internal.session.room.RoomAPI @@ -70,7 +70,11 @@ class SendRelationWorker(context: Context, params: WorkerParameters) return result.fold({ when (it) { is Failure.NetworkConnection -> Result.retry() - else -> Result.failure() + else -> { + //TODO mark as failed to send? + //always return success, or the chain will be stuck for ever! + Result.success() + } } }, { Result.success() }) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/annotation/UpdateQuickReactionTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/UpdateQuickReactionTask.kt similarity index 98% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/annotation/UpdateQuickReactionTask.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/UpdateQuickReactionTask.kt index 56a8545b..7a28683a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/annotation/UpdateQuickReactionTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/UpdateQuickReactionTask.kt @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package im.vector.matrix.android.internal.session.room.annotation +package im.vector.matrix.android.internal.session.room.relation import arrow.core.Try import com.zhuinden.monarchy.Monarchy diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt index 28ae63b8..d5b8a1c7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt @@ -24,18 +24,12 @@ import im.vector.matrix.android.api.session.room.send.SendService import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.CancelableBag import im.vector.matrix.android.api.util.addTo -import im.vector.matrix.android.internal.database.helper.addSendingEvent -import im.vector.matrix.android.internal.database.model.ChunkEntity -import im.vector.matrix.android.internal.database.model.RoomEntity -import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom -import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.session.content.UploadContentWorker +import im.vector.matrix.android.internal.session.room.timeline.TimelineSendEventWorkCommon import im.vector.matrix.android.internal.util.CancelableWork import im.vector.matrix.android.internal.util.WorkerParamsFactory -import im.vector.matrix.android.internal.util.tryTransactionAsync import java.util.concurrent.TimeUnit -private const val SEND_WORK = "SEND_WORK" private const val UPLOAD_WORK = "UPLOAD_WORK" private const val BACKOFF_DELAY = 10_000L @@ -49,14 +43,21 @@ internal class DefaultSendService(private val roomId: String, : SendService { - override fun sendTextMessage(text: String, msgType: String): Cancelable { - val event = eventFactory.createTextEvent(roomId, msgType, text).also { + override fun sendTextMessage(text: String, msgType: String, autoMarkdown: Boolean): Cancelable { + val event = eventFactory.createTextEvent(roomId, msgType, text, autoMarkdown).also { saveLocalEcho(it) } val sendWork = createSendEventWork(event) - WorkManager.getInstance() - .beginUniqueWork(buildWorkIdentifier(SEND_WORK), ExistingWorkPolicy.APPEND, sendWork) - .enqueue() + TimelineSendEventWorkCommon.postWork(roomId, sendWork) + return CancelableWork(sendWork.id) + } + + override fun sendFormattedTextMessage(text: String, formattedText: String): Cancelable { + val event = eventFactory.createFormattedTextEvent(roomId, text, formattedText).also { + saveLocalEcho(it) + } + val sendWork = createSendEventWork(event) + TimelineSendEventWorkCommon.postWork(roomId, sendWork) return CancelableWork(sendWork.id) } @@ -69,12 +70,9 @@ internal class DefaultSendService(private val roomId: String, } override fun redactEvent(event: Event, reason: String?): Cancelable { - //TODO manage local echo ? //TODO manage media/attachements? val redactWork = createRedactEventWork(event, reason) - WorkManager.getInstance() - .beginUniqueWork(buildWorkIdentifier(SEND_WORK), ExistingWorkPolicy.APPEND, redactWork) - .enqueue() + TimelineSendEventWorkCommon.postWork(roomId, redactWork) return CancelableWork(redactWork.id) } @@ -95,14 +93,7 @@ internal class DefaultSendService(private val roomId: String, } private fun saveLocalEcho(event: Event) { - monarchy.tryTransactionAsync { realm -> - val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst() - ?: return@tryTransactionAsync - val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId = roomId) - ?: return@tryTransactionAsync - - roomEntity.addSendingEvent(event, liveChunk.forwardsStateIndex ?: 0) - } + eventFactory.saveLocalEcho(monarchy, event) } private fun buildWorkIdentifier(identifier: String): String { @@ -113,26 +104,20 @@ internal class DefaultSendService(private val roomId: String, val sendContentWorkerParams = SendEventWorker.Params(roomId, event) val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams) - return OneTimeWorkRequestBuilder() - .setConstraints(WORK_CONSTRAINTS) - .setInputData(sendWorkData) - .setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS) - .build() + return TimelineSendEventWorkCommon.createWork(sendWorkData) } private fun createRedactEventWork(event: Event, reason: String?): OneTimeWorkRequest { - //TODO create local echo of m.room.redaction event? + val redactEvent = eventFactory.createRedactEvent(roomId, event.eventId!!, reason).also { + saveLocalEcho(it) + } - val sendContentWorkerParams = RedactEventWorker.Params( - roomId, event.eventId!!, reason) + val sendContentWorkerParams = RedactEventWorker.Params(redactEvent.eventId!!, + roomId, event.eventId, reason) val redactWorkData = WorkerParamsFactory.toData(sendContentWorkerParams) - return OneTimeWorkRequestBuilder() - .setConstraints(WORK_CONSTRAINTS) - .setInputData(redactWorkData) - .setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS) - .build() + return TimelineSendEventWorkCommon.createWork(redactWorkData) } private fun createUploadMediaWork(event: Event, attachment: ContentAttachmentData): OneTimeWorkRequest { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt index a2076d3b..e54af1ca 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/LocalEchoEventFactory.kt @@ -17,30 +17,104 @@ package im.vector.matrix.android.internal.session.room.send import android.media.MediaMetadataRetriever +import com.zhuinden.monarchy.Monarchy +import im.vector.matrix.android.R import im.vector.matrix.android.api.auth.data.Credentials +import im.vector.matrix.android.api.permalinks.PermalinkFactory import im.vector.matrix.android.api.session.content.ContentAttachmentData -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.RelationType -import im.vector.matrix.android.api.session.events.model.toContent -import im.vector.matrix.android.api.session.room.model.annotation.ReactionContent -import im.vector.matrix.android.api.session.room.model.annotation.ReactionInfo +import im.vector.matrix.android.api.session.events.model.* import im.vector.matrix.android.api.session.room.model.message.* +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.internal.database.helper.addSendingEvent +import im.vector.matrix.android.internal.database.model.ChunkEntity +import im.vector.matrix.android.internal.database.model.RoomEntity +import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom +import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.session.content.ThumbnailExtractor +import im.vector.matrix.android.internal.util.StringProvider +import im.vector.matrix.android.internal.util.tryTransactionAsync +import org.commonmark.parser.Parser +import org.commonmark.renderer.html.HtmlRenderer +import java.util.* -internal class LocalEchoEventFactory(private val credentials: Credentials) { +/** + * Creates local echo of events for room events. + * A local echo is an event that is persisted even if not yet sent to the server, + * in an optimistic way (as if the server as responded immediately). Local echo are using a local id, + * (the transaction ID), this id is used when receiving an event from a sync to check if this event + * is matching an existing local echo. + * + * The transactionID is used as loc + */ +internal class LocalEchoEventFactory(private val credentials: Credentials, private val stringProvider: StringProvider) { - fun createTextEvent(roomId: String, msgType: String, text: String): Event { + 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) + } + } val content = MessageTextContent(type = msgType, body = text) return createEvent(roomId, content) } + private fun isFormattedTextPertinent(text: String, htmlText: String?) = + text != htmlText && htmlText != "

$text

\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 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) + } + fun createMediaEvent(roomId: String, attachment: ContentAttachmentData): Event { return when (attachment.type) { ContentAttachmentData.Type.IMAGE -> createImageEvent(roomId, attachment) ContentAttachmentData.Type.VIDEO -> createVideoEvent(roomId, attachment) ContentAttachmentData.Type.AUDIO -> createAudioEvent(roomId, attachment) - ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment) + ContentAttachmentData.Type.FILE -> createFileEvent(roomId, attachment) } } @@ -52,14 +126,16 @@ internal class LocalEchoEventFactory(private val credentials: Credentials) { reaction ) ) + val localId = dummyEventId(roomId) return Event( roomId = roomId, originServerTs = dummyOriginServerTs(), sender = credentials.userId, - eventId = dummyEventId(roomId), + eventId = localId, type = EventType.REACTION, - content = content.toContent() - ) + content = content.toContent(), + unsignedData = UnsignedData(age = null, transactionId = localId)) + } @@ -141,13 +217,15 @@ internal class LocalEchoEventFactory(private val credentials: Credentials) { } private fun createEvent(roomId: String, content: Any? = null): Event { + val localID = dummyEventId(roomId) return Event( roomId = roomId, originServerTs = dummyOriginServerTs(), sender = credentials.userId, - eventId = dummyEventId(roomId), + eventId = localID, type = EventType.MESSAGE, - content = content.toContent() + content = content.toContent(), + unsignedData = UnsignedData(age = null, transactionId = localID) ) } @@ -156,6 +234,120 @@ internal class LocalEchoEventFactory(private val credentials: Credentials) { } private fun dummyEventId(roomId: String): String { - return roomId + "-" + dummyOriginServerTs() + return "m.${UUID.randomUUID()}" } + + fun createReplyTextEvent(roomId: String, eventReplied: Event, replyText: String): 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.sender ?: return null + val userLink = PermalinkFactory.createPermalink(userId) ?: return null +// +//
+// In reply to +// @alice:example.org +//
+// +//
+//
+// This is where the reply goes. + val body = bodyForReply(eventReplied.content.toModel()) + val replyFallbackTemplateFormatted = """ +
${stringProvider.getString(R.string.message_reply_to_prefix)}%s
%s
%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") } + lines.forEachIndexed { index, s -> + if (index > 0) { + plainTextBody.append("\n>$s") + } + } + plainTextBody.append("\n\n").append(replyText) + + val eventId = eventReplied.eventId ?: return null + val content = MessageTextContent( + type = MessageType.MSGTYPE_TEXT, + format = MessageType.FORMAT_MATRIX_HTML, + body = plainTextBody.toString(), + formattedBody = replyFallbackTemplateFormatted, + relatesTo = RelationDefaultContent(null, null, ReplyToContent(eventId)) + ) + return createEvent(roomId, content) + } + + /** + * Returns a pair of used for the fallback event representation + * in a reply message. + */ + private fun bodyForReply(content: MessageContent?): Pair { + 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 + } + 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 + + } + + } + + /* + * { + "content": { + "reason": "Spamming" + }, + "event_id": "$143273582443PhrSn:domain.com", + "origin_server_ts": 1432735824653, + "redacts": "$fukweghifu23:localhost", + "room_id": "!jEsUZKDJdhlrceRyVU:domain.com", + "sender": "@example:domain.com", + "type": "m.room.redaction", + "unsigned": { + "age": 1234 + } + } + */ + fun createRedactEvent(roomId: String, eventId: String, reason: String?): Event { + val localID = dummyEventId(roomId) + return Event( + roomId = roomId, + originServerTs = dummyOriginServerTs(), + sender = credentials.userId, + eventId = localID, + type = EventType.REDACTION, + redacts = eventId, + content = reason?.let { mapOf("reason" to it).toContent() }, + unsignedData = UnsignedData(age = null, transactionId = localID) + ) + } + + fun saveLocalEcho(monarchy: Monarchy, event: Event) { + monarchy.tryTransactionAsync { realm -> + val roomEntity = RoomEntity.where(realm, roomId = event.roomId!!).findFirst() + ?: return@tryTransactionAsync + val liveChunk = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId = event.roomId) + ?: return@tryTransactionAsync + + roomEntity.addSendingEvent(event, liveChunk.forwardsStateIndex ?: 0) + } + } + } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/RedactEventWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/RedactEventWorker.kt index e0bc740e..5056a93b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/RedactEventWorker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/RedactEventWorker.kt @@ -25,13 +25,13 @@ import im.vector.matrix.android.internal.network.executeRequest import im.vector.matrix.android.internal.session.room.RoomAPI import im.vector.matrix.android.internal.util.WorkerParamsFactory import org.koin.standalone.inject -import java.util.* internal class RedactEventWorker(context: Context, params: WorkerParameters) : Worker(context, params), MatrixKoinComponent { @JsonClass(generateAdapter = true) internal data class Params( + val txID: String, val roomId: String, val eventId: String, val reason: String? @@ -40,26 +40,26 @@ internal class RedactEventWorker(context: Context, params: WorkerParameters) private val roomAPI by inject() override fun doWork(): Result { - val params = WorkerParamsFactory.fromData(inputData) + val params = WorkerParamsFactory.fromData(inputData) ?: return Result.failure() - if (params.eventId == null) { - return Result.failure() - } - val txID = UUID.randomUUID().toString() - + val eventId = params.eventId val result = executeRequest { apiCall = roomAPI.redactEvent( - txID, + params.txID, params.roomId, - params.eventId, + eventId, if (params.reason == null) emptyMap() else mapOf("reason" to params.reason) ) } return result.fold({ when (it) { is Failure.NetworkConnection -> Result.retry() - else -> Result.failure() + else -> { + //TODO mark as failed to send? + //always return success, or the chain will be stuck for ever! + Result.success() + } } }, { Result.success() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/SendEventWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/SendEventWorker.kt index 864ef13e..f9a0478b 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/SendEventWorker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/SendEventWorker.kt @@ -20,6 +20,7 @@ import android.content.Context import androidx.work.Worker import androidx.work.WorkerParameters import com.squareup.moshi.JsonClass +import im.vector.matrix.android.api.failure.Failure import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.internal.di.MatrixKoinComponent import im.vector.matrix.android.internal.network.executeRequest @@ -57,6 +58,15 @@ internal class SendEventWorker(context: Context, params: WorkerParameters) localEvent.content ) } - return result.fold({ Result.retry() }, { Result.success() }) + return result.fold({ + when (it) { + is Failure.NetworkConnection -> Result.retry() + else -> { + //TODO mark as failed to send? + //always return success, or the chain will be stuck for ever! + Result.success() + } + } + }, { Result.success() }) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineSendEventWorkCommon.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineSendEventWorkCommon.kt new file mode 100644 index 00000000..8205f2cc --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TimelineSendEventWorkCommon.kt @@ -0,0 +1,56 @@ +/* + * 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.timeline + +import androidx.work.* +import java.util.concurrent.TimeUnit + + +private const val SEND_WORK = "SEND_WORK" +private const val BACKOFF_DELAY = 10_000L + +private val WORK_CONSTRAINTS = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + +/** + * Helper class for sending event related works. + * All send event from a room are using the same workchain, in order to ensure order. + * WorkRequest must always return success (even if server error, in this case marking the event as failed to send) + * , if not the chain will be doomed in failed state. + * + */ +internal object TimelineSendEventWorkCommon { + + fun postWork(roomId: String, workRequest: OneTimeWorkRequest) { + WorkManager.getInstance() + .beginUniqueWork(buildWorkIdentifier(roomId), ExistingWorkPolicy.APPEND, workRequest) + .enqueue() + + } + + inline fun createWork(data: Data): OneTimeWorkRequest { + return OneTimeWorkRequestBuilder() + .setConstraints(WORK_CONSTRAINTS) + .setInputData(data) + .setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS) + .build() + } + + private fun buildWorkIdentifier(roomId: String): String { + return "${roomId}_$SEND_WORK" + } +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt index ec85c558..9c40b4aa 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/timeline/TokenChunkEventPersistor.kt @@ -18,20 +18,13 @@ package im.vector.matrix.android.internal.session.room.timeline import arrow.core.Try import com.zhuinden.monarchy.Monarchy -import im.vector.matrix.android.internal.database.helper.addAll -import im.vector.matrix.android.internal.database.helper.addOrUpdate -import im.vector.matrix.android.internal.database.helper.addStateEvents -import im.vector.matrix.android.internal.database.helper.deleteOnCascade -import im.vector.matrix.android.internal.database.helper.isUnlinked -import im.vector.matrix.android.internal.database.helper.merge -import im.vector.matrix.android.internal.database.mapper.EventMapper +import im.vector.matrix.android.internal.database.helper.* import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.query.create import im.vector.matrix.android.internal.database.query.find import im.vector.matrix.android.internal.database.query.findAllIncludingEvents import im.vector.matrix.android.internal.database.query.where -import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater import im.vector.matrix.android.internal.util.tryTransactionSync import io.realm.kotlin.createObject import timber.log.Timber @@ -39,8 +32,7 @@ import timber.log.Timber /** * Insert Chunk in DB, and eventually merge with existing chunk event */ -internal class TokenChunkEventPersistor(private val monarchy: Monarchy, - private val eventRelationsAggregationUpdater: EventRelationsAggregationUpdater) { +internal class TokenChunkEventPersistor(private val monarchy: Monarchy) { /** *
@@ -119,7 +111,7 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy,
                     Timber.v("Start persisting ${receivedChunk.events.size} events in $roomId towards $direction")
 
                     val roomEntity = RoomEntity.where(realm, roomId).findFirst()
-                                     ?: realm.createObject(roomId)
+                            ?: realm.createObject(roomId)
 
                     val nextToken: String?
                     val prevToken: String?
@@ -142,7 +134,7 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy,
                     } else {
                         nextChunk?.apply { this.prevToken = prevToken }
                     }
-                                       ?: ChunkEntity.create(realm, prevToken, nextToken)
+                            ?: ChunkEntity.create(realm, prevToken, nextToken)
 
                     if (receivedChunk.events.isEmpty() && receivedChunk.end == receivedChunk.start) {
                         Timber.v("Reach end of $roomId")
@@ -151,8 +143,6 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy,
                         Timber.v("Add ${receivedChunk.events.size} events in chunk(${currentChunk.nextToken} | ${currentChunk.prevToken}")
                         currentChunk.addAll(roomId, receivedChunk.events, direction, isUnlinked = currentChunk.isUnlinked())
 
-                        //Event
-                        eventRelationsAggregationUpdater.update(realm,roomId,receivedChunk.events.toList())
                         // Then we merge chunks if needed
                         if (currentChunk != prevChunk && prevChunk != null) {
                             currentChunk = handleMerge(roomEntity, direction, currentChunk, prevChunk)
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt
index eaab9846..018cc518 100644
--- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/RoomSyncHandler.kt
@@ -31,14 +31,9 @@ import im.vector.matrix.android.internal.database.model.RoomEntity
 import im.vector.matrix.android.internal.database.query.find
 import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
 import im.vector.matrix.android.internal.database.query.where
-import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater
 import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater
 import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection
-import im.vector.matrix.android.internal.session.sync.model.InvitedRoomSync
-import im.vector.matrix.android.internal.session.sync.model.RoomSync
-import im.vector.matrix.android.internal.session.sync.model.RoomSyncAccountData
-import im.vector.matrix.android.internal.session.sync.model.RoomSyncEphemeral
-import im.vector.matrix.android.internal.session.sync.model.RoomsSyncResponse
+import im.vector.matrix.android.internal.session.sync.model.*
 import io.realm.Realm
 import io.realm.kotlin.createObject
 import timber.log.Timber
@@ -46,8 +41,7 @@ import timber.log.Timber
 internal class RoomSyncHandler(private val monarchy: Monarchy,
                                private val readReceiptHandler: ReadReceiptHandler,
                                private val roomSummaryUpdater: RoomSummaryUpdater,
-                               private val roomTagHandler: RoomTagHandler,
-                               private val eventRelationsAggregationUpdater: EventRelationsAggregationUpdater) {
+                               private val roomTagHandler: RoomTagHandler) {
 
     sealed class HandlingStrategy {
         data class JOINED(val data: Map) : HandlingStrategy()
@@ -67,9 +61,9 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
 
     private fun handleRoomSync(realm: Realm, handlingStrategy: HandlingStrategy) {
         val rooms = when (handlingStrategy) {
-            is HandlingStrategy.JOINED  -> handlingStrategy.data.map { handleJoinedRoom(realm, it.key, it.value) }
+            is HandlingStrategy.JOINED -> handlingStrategy.data.map { handleJoinedRoom(realm, it.key, it.value) }
             is HandlingStrategy.INVITED -> handlingStrategy.data.map { handleInvitedRoom(realm, it.key, it.value) }
-            is HandlingStrategy.LEFT    -> handlingStrategy.data.map { handleLeftRoom(realm, it.key, it.value) }
+            is HandlingStrategy.LEFT -> handlingStrategy.data.map { handleLeftRoom(realm, it.key, it.value) }
         }
         realm.insertOrUpdate(rooms)
     }
@@ -81,7 +75,7 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
         Timber.v("Handle join sync for room $roomId")
 
         val roomEntity = RoomEntity.where(realm, roomId).findFirst()
-                         ?: realm.createObject(roomId)
+                ?: realm.createObject(roomId)
 
         if (roomEntity.membership == Membership.INVITE) {
             roomEntity.chunks.deleteAllFromRealm()
@@ -116,11 +110,13 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
             transactionIds.forEach {
                 val sendingEventEntity = roomEntity.sendingTimelineEvents.find(it)
                 if (sendingEventEntity != null) {
+                    Timber.v("Remove local echo for tx:$it")
                     roomEntity.sendingTimelineEvents.remove(sendingEventEntity)
+                } else {
+                    Timber.v("Can't find corresponding local echo for tx:$it")
                 }
             }
         }
-        eventRelationsAggregationUpdater.update(realm, roomId, roomSync.timeline?.events)
         roomSummaryUpdater.update(realm, roomId, Membership.JOIN, roomSync.summary, roomSync.unreadNotifications)
 
         if (roomSync.ephemeral != null && roomSync.ephemeral.events.isNotEmpty()) {
@@ -139,7 +135,7 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
                                   InvitedRoomSync): RoomEntity {
         Timber.v("Handle invited sync for room $roomId")
         val roomEntity = RoomEntity.where(realm, roomId).findFirst()
-                         ?: realm.createObject(roomId)
+                ?: realm.createObject(roomId)
         roomEntity.membership = Membership.INVITE
         if (roomSync.inviteState != null && roomSync.inviteState.events.isNotEmpty()) {
             val chunkEntity = handleTimelineEvents(realm, roomId, roomSync.inviteState.events)
@@ -153,7 +149,7 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
                                roomId: String,
                                roomSync: RoomSync): RoomEntity {
         val roomEntity = RoomEntity.where(realm, roomId).findFirst()
-                         ?: realm.createObject(roomId)
+                ?: realm.createObject(roomId)
 
         roomEntity.membership = Membership.LEAVE
         roomEntity.chunks.deleteAllFromRealm()
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncModule.kt
index d6014061..d0f60405 100644
--- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncModule.kt
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/sync/SyncModule.kt
@@ -40,7 +40,7 @@ internal class SyncModule {
         }
 
         scope(DefaultSession.SCOPE) {
-            RoomSyncHandler(get(), get(), get(), get(), get())
+            RoomSyncHandler(get(), get(), get(), get())
         }
 
         scope(DefaultSession.SCOPE) {
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/StringProvider.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/StringProvider.kt
new file mode 100644
index 00000000..02d90c2f
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/StringProvider.kt
@@ -0,0 +1,55 @@
+/*
+ * 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.util
+
+import android.content.res.Resources
+import androidx.annotation.NonNull
+import androidx.annotation.StringRes
+
+internal class StringProvider(private val resources: Resources) {
+
+    /**
+     * Returns a localized string from the application's package's
+     * default string table.
+     *
+     * @param resId Resource id for the string
+     * @return The string data associated with the resource, stripped of styled
+     * text information.
+     */
+    @NonNull
+    fun getString(@StringRes resId: Int): String {
+        return resources.getString(resId)
+    }
+
+    /**
+     * Returns a localized formatted string from the application's package's
+     * default string table, substituting the format arguments as defined in
+     * [java.util.Formatter] and [java.lang.String.format].
+     *
+     * @param resId Resource id for the format string
+     * @param formatArgs The format arguments that will be used for
+     * substitution.
+     * @return The string data associated with the resource, formatted and
+     * stripped of styled text information.
+     */
+    @NonNull
+    fun getString(@StringRes resId: Int, vararg formatArgs: Any?): String {
+        return resources.getString(resId, *formatArgs)
+    }
+
+
+}
\ No newline at end of file
diff --git a/vector/src/main/java/im/vector/riotredesign/core/platform/VectorBaseActivity.kt b/vector/src/main/java/im/vector/riotredesign/core/platform/VectorBaseActivity.kt
index 6899bf1c..de1f0df0 100644
--- a/vector/src/main/java/im/vector/riotredesign/core/platform/VectorBaseActivity.kt
+++ b/vector/src/main/java/im/vector/riotredesign/core/platform/VectorBaseActivity.kt
@@ -88,7 +88,6 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
     }
 
     protected fun Disposable.disposeOnDestroy(): Disposable {
-        // TODO Ganfra: never disposed...
         uiDisposables.add(this)
         return this
     }
@@ -128,6 +127,8 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
 
         unBinder?.unbind()
         unBinder = null
+
+        uiDisposables.dispose()
     }
 
     override fun onResume() {
diff --git a/vector/src/main/java/im/vector/riotredesign/features/autocomplete/command/CommandAutocompletePolicy.kt b/vector/src/main/java/im/vector/riotredesign/features/autocomplete/command/CommandAutocompletePolicy.kt
index 74ee50aa..38556cdb 100644
--- a/vector/src/main/java/im/vector/riotredesign/features/autocomplete/command/CommandAutocompletePolicy.kt
+++ b/vector/src/main/java/im/vector/riotredesign/features/autocomplete/command/CommandAutocompletePolicy.kt
@@ -20,11 +20,13 @@ import android.text.Spannable
 import com.otaliastudios.autocomplete.AutocompletePolicy
 
 class CommandAutocompletePolicy : AutocompletePolicy {
+
+    var enabled: Boolean = true
+
     override fun getQuery(text: Spannable): CharSequence {
         if (text.length > 0) {
             return text.substring(1, text.length)
         }
-
         // Should not happen
         return ""
     }
@@ -34,7 +36,7 @@ class CommandAutocompletePolicy : AutocompletePolicy {
 
     // Only if text which starts with '/' and without space
     override fun shouldShowPopup(text: Spannable?, cursorPos: Int): Boolean {
-        return text?.startsWith("/") == true
+        return enabled && text?.startsWith("/") == true
                 && !text.contains(" ")
     }
 
diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/HomeModule.kt b/vector/src/main/java/im/vector/riotredesign/features/home/HomeModule.kt
index cd57800e..d6297b6f 100644
--- a/vector/src/main/java/im/vector/riotredesign/features/home/HomeModule.kt
+++ b/vector/src/main/java/im/vector/riotredesign/features/home/HomeModule.kt
@@ -72,9 +72,10 @@ class HomeModule {
             val timelineMediaSizeProvider = TimelineMediaSizeProvider()
             val colorProvider = ColorProvider(fragment.requireContext())
             val timelineDateFormatter = get()
+            val messageItemFactory = MessageItemFactory(colorProvider, timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer, get())
 
             val timelineItemFactory = TimelineItemFactory(
-                    messageItemFactory = MessageItemFactory(colorProvider, timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer),
+                    messageItemFactory = messageItemFactory,
                     noticeItemFactory = NoticeItemFactory(noticeEventFormatter),
                     defaultItemFactory = DefaultItemFactory()
             )
diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt
index c4732061..ddee224b 100644
--- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt
+++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt
@@ -23,17 +23,21 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
 
 sealed class RoomDetailActions {
 
-    data class SendMessage(val text: String) : RoomDetailActions()
+    data class SendMessage(val text: String, val autoMarkdown: Boolean) : RoomDetailActions()
     data class SendMedia(val mediaFiles: List) : RoomDetailActions()
     data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
     data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions()
     data class SendReaction(val reaction: String, val targetEventId: String) : RoomDetailActions()
     data class RedactAction(val targetEventId: String, val reason: String? = "") : RoomDetailActions()
     data class UndoReaction(val targetEventId: String, val key: String, val reason: String? = "") : RoomDetailActions()
-    data class UpdateQuickReactAction(val targetEventId: String,val selectedReaction: String,val opposite: String) : RoomDetailActions()
-    data class ShowEditHistoryAction(val event: String,val editAggregatedSummary: EditAggregatedSummary) : RoomDetailActions()
+    data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val opposite: String) : RoomDetailActions()
+    data class ShowEditHistoryAction(val event: String, val editAggregatedSummary: EditAggregatedSummary) : RoomDetailActions()
     object AcceptInvite : RoomDetailActions()
     object RejectInvite : RoomDetailActions()
 
+    data class EnterEditMode(val eventId: String) : RoomDetailActions()
+    data class EnterQuoteMode(val eventId: String) : RoomDetailActions()
+    data class EnterReplyMode(val eventId: String) : RoomDetailActions()
+
 
 }
\ No newline at end of file
diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt
index cdbd3cd6..98d0325a 100644
--- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt
+++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt
@@ -40,6 +40,7 @@ import androidx.lifecycle.Observer
 import androidx.lifecycle.ViewModelProviders
 import androidx.recyclerview.widget.LinearLayoutManager
 import androidx.recyclerview.widget.RecyclerView
+import butterknife.BindView
 import com.airbnb.epoxy.EpoxyVisibilityTracker
 import com.airbnb.mvrx.args
 import com.airbnb.mvrx.fragmentViewModel
@@ -53,6 +54,7 @@ import com.otaliastudios.autocomplete.Autocomplete
 import com.otaliastudios.autocomplete.AutocompleteCallback
 import com.otaliastudios.autocomplete.CharPolicy
 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.*
@@ -74,6 +76,7 @@ import im.vector.riotredesign.features.home.AvatarRenderer
 import im.vector.riotredesign.features.home.HomeModule
 import im.vector.riotredesign.features.home.HomePermalinkHandler
 import im.vector.riotredesign.features.home.room.detail.composer.TextComposerActions
+import im.vector.riotredesign.features.home.room.detail.composer.TextComposerView
 import im.vector.riotredesign.features.home.room.detail.composer.TextComposerViewModel
 import im.vector.riotredesign.features.home.room.detail.composer.TextComposerViewState
 import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
@@ -89,12 +92,17 @@ import im.vector.riotredesign.features.media.ImageMediaViewerActivity
 import im.vector.riotredesign.features.media.VideoContentRenderer
 import im.vector.riotredesign.features.media.VideoMediaViewerActivity
 import im.vector.riotredesign.features.reactions.EmojiReactionPickerActivity
+import im.vector.riotredesign.features.settings.PreferencesManager
 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 org.koin.android.ext.android.inject
 import org.koin.android.scope.ext.android.bindScope
 import org.koin.android.scope.ext.android.getOrCreateScope
 import org.koin.core.parameter.parametersOf
+import ru.noties.markwon.Markwon
+import ru.noties.markwon.html.HtmlPlugin
 import timber.log.Timber
 import java.io.File
 
@@ -132,13 +140,12 @@ class RoomDetailFragment :
          * @return the sanitized display name
          */
         fun sanitizeDisplayname(displayName: String): String? {
-            var displayName = displayName
             // sanity checks
             if (!TextUtils.isEmpty(displayName)) {
                 val ircPattern = " (IRC)"
 
                 if (displayName.endsWith(ircPattern)) {
-                    displayName = displayName.substring(0, displayName.length - ircPattern.length)
+                    return displayName.substring(0, displayName.length - ircPattern.length)
                 }
             }
 
@@ -155,6 +162,7 @@ class RoomDetailFragment :
     private val roomDetailViewModel: RoomDetailViewModel by fragmentViewModel()
     private val textComposerViewModel: TextComposerViewModel by fragmentViewModel()
     private val timelineEventController: TimelineEventController by inject { parametersOf(this) }
+    private val commandAutocompletePolicy = CommandAutocompletePolicy()
     private val autocompleteCommandPresenter: AutocompleteCommandPresenter by inject { parametersOf(this) }
     private val autocompleteUserPresenter: AutocompleteUserPresenter by inject { parametersOf(this) }
     private val homePermalinkHandler: HomePermalinkHandler by inject()
@@ -165,6 +173,9 @@ class RoomDetailFragment :
 
     private lateinit var actionViewModel: ActionsHandler
 
+    @BindView(R.id.composerLayout)
+    lateinit var composerLayout: TextComposerView
+
     override fun onActivityCreated(savedInstanceState: Bundle?) {
         super.onActivityCreated(savedInstanceState)
         actionViewModel = ViewModelProviders.of(requireActivity()).get(ActionsHandler::class.java)
@@ -187,6 +198,77 @@ class RoomDetailFragment :
         actionViewModel.actionCommandEvent.observe(this, Observer {
             handleActions(it)
         })
+
+        roomDetailViewModel.selectSubscribe(
+                RoomDetailViewState::sendMode,
+                RoomDetailViewState::selectedEvent,
+                RoomDetailViewState::roomId) { mode, event, roomId ->
+            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.senderName
+                        setTextColor(ContextCompat.getColor(requireContext(), AvatarRenderer.getColorFromUserId(event.root.sender
+                                ?: "")))
+                    }
+
+                    //TODO this is used at several places, find way to refactor?
+                    val messageContent: MessageContent? =
+                            event.annotations?.editSummary?.aggregatedContent?.toModel()
+                                    ?: event.root.content.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.sender
+                            ?: "", event.senderName, composerLayout.composerRelatedMessageAvatar)
+
+                    composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
+                    composerLayout.expand {
+                        focusComposerAndShowKeyboard()
+                    }
+                    composerLayout.composerRelatedMessageCloseButton.setOnClickListener {
+                        composerLayout.composerEditText.setText("")
+                        roomDetailViewModel.resetSendMode()
+                    }
+
+                }
+            }
+        }
     }
 
     override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@@ -233,8 +315,8 @@ class RoomDetailFragment :
     private fun setupComposer() {
         val elevation = 6f
         val backgroundDrawable = ColorDrawable(Color.WHITE)
-        Autocomplete.on(composerEditText)
-                .with(CommandAutocompletePolicy())
+        Autocomplete.on(composerLayout.composerEditText)
+                .with(commandAutocompletePolicy)
                 .with(autocompleteCommandPresenter)
                 .with(elevation)
                 .with(backgroundDrawable)
@@ -253,7 +335,7 @@ class RoomDetailFragment :
                 .build()
 
         autocompleteUserPresenter.callback = this
-        Autocomplete.on(composerEditText)
+        Autocomplete.on(composerLayout.composerEditText)
                 .with(CharPolicy('@', true))
                 .with(autocompleteUserPresenter)
                 .with(elevation)
@@ -281,7 +363,7 @@ class RoomDetailFragment :
                         // Add the span
                         val user = session.getUser(item.userId)
                         val span = PillImageSpan(glideRequests, context!!, item.userId, user)
-                        span.bind(composerEditText)
+                        span.bind(composerLayout.composerEditText)
 
                         editable.setSpan(span, startIndex, startIndex + displayName.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
 
@@ -293,16 +375,16 @@ class RoomDetailFragment :
                 })
                 .build()
 
-        sendButton.setOnClickListener {
-            val textMessage = composerEditText.text.toString()
+        composerLayout.sendButton.setOnClickListener {
+            val textMessage = composerLayout.composerEditText.text.toString()
             if (textMessage.isNotBlank()) {
-                roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage))
+                roomDetailViewModel.process(RoomDetailActions.SendMessage(textMessage, PreferencesManager.isMarkdownEnabled(requireContext())))
             }
         }
     }
 
     private fun setupAttachmentButton() {
-        attachmentButton.setOnClickListener {
+        composerLayout.attachmentButton.setOnClickListener {
             val intent = Intent(requireContext(), FilePickerActivity::class.java)
             intent.putExtra(FilePickerActivity.CONFIGS, Configurations.Builder()
                     .setCheckPermission(true)
@@ -386,6 +468,11 @@ class RoomDetailFragment :
         if (summary?.membership == Membership.JOIN) {
             timelineEventController.setTimeline(state.timeline)
             inviteView.visibility = View.GONE
+
+            val uid = session.sessionParams.credentials.userId
+            val meMember = session.getRoom(state.roomId)?.getRoomMember(uid)
+            AvatarRenderer.render(meMember?.avatarUrl, uid, meMember?.displayName, composerLayout.composerAvatarImageView)
+
         } else if (summary?.membership == Membership.INVITE && inviter != null) {
             inviteView.visibility = View.VISIBLE
             inviteView.render(inviter, VectorInviteView.Mode.LARGE)
@@ -416,7 +503,7 @@ class RoomDetailFragment :
             is SendMessageResult.MessageSent,
             is SendMessageResult.SlashCommandHandled        -> {
                 // Clear composer
-                composerEditText.text = null
+                composerLayout.composerEditText.text = null
             }
             is SendMessageResult.SlashCommandError          -> {
                 displayCommandError(getString(R.string.command_problem_with_parameters, sendMessageResult.command.command))
@@ -586,6 +673,18 @@ class RoomDetailFragment :
                         roomDetailViewModel.process(RoomDetailActions.UpdateQuickReactAction(eventId, clickedOn, opposite))
                     }
                 }
+                MessageMenuViewModel.ACTION_EDIT           -> {
+                    val eventId = actionData.data.toString()
+                    roomDetailViewModel.process(RoomDetailActions.EnterEditMode(eventId))
+                }
+                MessageMenuViewModel.ACTION_QUOTE          -> {
+                    val eventId = actionData.data.toString()
+                    roomDetailViewModel.process(RoomDetailActions.EnterQuoteMode(eventId))
+                }
+                MessageMenuViewModel.ACTION_REPLY          -> {
+                    val eventId = actionData.data.toString()
+                    roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId))
+                }
                 else                                       -> {
                     Toast.makeText(context, "Action ${actionData.actionId} not implemented", Toast.LENGTH_LONG).show()
                 }
@@ -599,6 +698,7 @@ class RoomDetailFragment :
      *
      * @param text the text to insert.
      */
+    //TODO legacy, refactor
     private fun insertUserDisplayNameInTextEditor(text: String?) {
         //TODO move logic outside of fragment
         if (null != text) {
@@ -607,21 +707,21 @@ class RoomDetailFragment :
             val myDisplayName = session.getUser(session.sessionParams.credentials.userId)?.displayName
             if (TextUtils.equals(myDisplayName, text)) {
                 // current user
-                if (TextUtils.isEmpty(composerEditText.text)) {
-                    composerEditText.append(Command.EMOTE.command + " ")
-                    composerEditText.setSelection(composerEditText.text.length)
+                if (TextUtils.isEmpty(composerLayout.composerEditText.text)) {
+                    composerLayout.composerEditText.append(Command.EMOTE.command + " ")
+                    composerLayout.composerEditText.setSelection(composerLayout.composerEditText.text.length)
 //                    vibrate = true
                 }
             } else {
                 // another user
-                if (TextUtils.isEmpty(composerEditText.text)) {
+                if (TextUtils.isEmpty(composerLayout.composerEditText.text)) {
                     // Ensure displayName will not be interpreted as a Slash command
                     if (text.startsWith("/")) {
-                        composerEditText.append("\\")
+                        composerLayout.composerEditText.append("\\")
                     }
-                    composerEditText.append(sanitizeDisplayname(text)!! + ": ")
+                    composerLayout.composerEditText.append(sanitizeDisplayname(text)!! + ": ")
                 } else {
-                    composerEditText.text.insert(composerEditText.selectionStart, sanitizeDisplayname(text)!! + " ")
+                    composerLayout.composerEditText.text.insert(composerLayout.composerEditText.selectionStart, sanitizeDisplayname(text)!! + " ")
                 }
 
 //                vibrate = true
@@ -633,12 +733,16 @@ class RoomDetailFragment :
 //                    v.vibrate(100)
 //                }
 //            }
-            composerEditText.requestFocus()
-            val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
-            imm?.showSoftInput(composerEditText, InputMethodManager.SHOW_FORCED)
+            focusComposerAndShowKeyboard()
         }
     }
 
+    private fun focusComposerAndShowKeyboard() {
+        composerLayout.composerEditText.requestFocus()
+        val imm = context?.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
+        imm?.showSoftInput(composerLayout.composerEditText, InputMethodManager.SHOW_IMPLICIT)
+    }
+
     fun showSnackWithMessage(message: String, duration: Int = Snackbar.LENGTH_SHORT) {
         val snack = Snackbar.make(view!!, message, duration)
         snack.view.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.notification_accent_color))
diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt
index 1a5b58d8..b02a71e8 100644
--- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt
+++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt
@@ -16,6 +16,7 @@
 
 package im.vector.riotredesign.features.home.room.detail
 
+import android.text.TextUtils
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
 import com.airbnb.mvrx.MvRxViewModelFactory
@@ -25,8 +26,11 @@ import com.jakewharton.rxrelay2.BehaviorRelay
 import im.vector.matrix.android.api.MatrixCallback
 import im.vector.matrix.android.api.session.Session
 import im.vector.matrix.android.api.session.content.ContentAttachmentData
+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.message.MessageContent
 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.rx.rx
 import im.vector.riotredesign.R
 import im.vector.riotredesign.core.platform.VectorViewModel
@@ -35,11 +39,14 @@ import im.vector.riotredesign.features.command.CommandParser
 import im.vector.riotredesign.features.command.ParsedCommand
 import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDisplayableEvents
 import io.reactivex.rxkotlin.subscribeBy
+import org.commonmark.parser.Parser
+import org.commonmark.renderer.html.HtmlRenderer
 import org.koin.android.ext.android.get
 import java.text.SimpleDateFormat
 import java.util.*
 import java.util.concurrent.TimeUnit
 
+
 class RoomDetailViewModel(initialState: RoomDetailViewState,
                           private val session: Session
 ) : VectorViewModel(initialState) {
@@ -83,9 +90,29 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
             is RoomDetailActions.UndoReaction           -> handleUndoReact(action)
             is RoomDetailActions.UpdateQuickReactAction -> handleUpdateQuickReaction(action)
             is RoomDetailActions.ShowEditHistoryAction  -> handleShowEditHistoryReaction(action)
+            is RoomDetailActions.EnterEditMode          -> handleEditAction(action)
+            is RoomDetailActions.EnterQuoteMode         -> handleQuoteAction(action)
+            is RoomDetailActions.EnterReplyMode         -> handleReplyAction(action)
         }
     }
 
+    fun enterEditMode(event: TimelineEvent) {
+        setState {
+            copy(
+                    sendMode = SendMode.EDIT,
+                    selectedEvent = event
+            )
+        }
+    }
+
+    fun resetSendMode() {
+        setState {
+            copy(
+                    sendMode = SendMode.REGULAR,
+                    selectedEvent = null
+            )
+        }
+    }
 
     private val _nonBlockingPopAlert = MutableLiveData>>>()
     val nonBlockingPopAlert: LiveData>>>
@@ -99,71 +126,145 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
     // PRIVATE METHODS *****************************************************************************
 
     private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
-        // Handle slash command
-        val slashCommandResult = CommandParser.parseSplashCommand(action.text)
+        withState { state ->
+            when (state.sendMode) {
+                SendMode.REGULAR -> {
+                    val slashCommandResult = CommandParser.parseSplashCommand(action.text)
 
-        when (slashCommandResult) {
-            is ParsedCommand.ErrorNotACommand         -> {
-                // Send the text message to the room
-                room.sendTextMessage(action.text)
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
-            }
-            is ParsedCommand.ErrorSyntax              -> {
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command)))
-            }
-            is ParsedCommand.ErrorEmptySlashCommand   -> {
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown("/")))
-            }
-            is ParsedCommand.ErrorUnknownSlashCommand -> {
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown(slashCommandResult.slashCommand)))
-            }
-            is ParsedCommand.Invite                   -> {
-                handleInviteSlashCommand(slashCommandResult)
-            }
-            is ParsedCommand.SetUserPowerLevel        -> {
-                // TODO
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
-            }
-            is ParsedCommand.ClearScalarToken         -> {
-                // TODO
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
-            }
-            is ParsedCommand.SetMarkdown              -> {
-                // TODO
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
-            }
-            is ParsedCommand.UnbanUser                -> {
-                // TODO
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
-            }
-            is ParsedCommand.BanUser                  -> {
-                // TODO
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
-            }
-            is ParsedCommand.KickUser                 -> {
-                // TODO
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
-            }
-            is ParsedCommand.JoinRoom                 -> {
-                // TODO
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
-            }
-            is ParsedCommand.PartRoom                 -> {
-                // TODO
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
-            }
-            is ParsedCommand.SendEmote                -> {
-                room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE)
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled))
-            }
-            is ParsedCommand.ChangeTopic              -> {
-                handleChangeTopicSlashCommand(slashCommandResult)
-            }
-            is ParsedCommand.ChangeDisplayName        -> {
-                // TODO
-                _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
+                    when (slashCommandResult) {
+                        is ParsedCommand.ErrorNotACommand         -> {
+                            // Send the text message to the room
+                            room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown)
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
+                        }
+                        is ParsedCommand.ErrorSyntax              -> {
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandError(slashCommandResult.command)))
+                        }
+                        is ParsedCommand.ErrorEmptySlashCommand   -> {
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown("/")))
+                        }
+                        is ParsedCommand.ErrorUnknownSlashCommand -> {
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandUnknown(slashCommandResult.slashCommand)))
+                        }
+                        is ParsedCommand.Invite                   -> {
+                            handleInviteSlashCommand(slashCommandResult)
+                        }
+                        is ParsedCommand.SetUserPowerLevel        -> {
+                            // TODO
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
+                        }
+                        is ParsedCommand.ClearScalarToken         -> {
+                            // TODO
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
+                        }
+                        is ParsedCommand.SetMarkdown              -> {
+                            // TODO
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
+                        }
+                        is ParsedCommand.UnbanUser                -> {
+                            // TODO
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
+                        }
+                        is ParsedCommand.BanUser                  -> {
+                            // TODO
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
+                        }
+                        is ParsedCommand.KickUser                 -> {
+                            // TODO
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
+                        }
+                        is ParsedCommand.JoinRoom                 -> {
+                            // TODO
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
+                        }
+                        is ParsedCommand.PartRoom                 -> {
+                            // TODO
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
+                        }
+                        is ParsedCommand.SendEmote                -> {
+                            room.sendTextMessage(slashCommandResult.message, msgType = MessageType.MSGTYPE_EMOTE)
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandHandled))
+                        }
+                        is ParsedCommand.ChangeTopic              -> {
+                            handleChangeTopicSlashCommand(slashCommandResult)
+                        }
+                        is ParsedCommand.ChangeDisplayName        -> {
+                            // TODO
+                            _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.SlashCommandNotImplemented))
+                        }
+                    }
+                }
+                SendMode.EDIT    -> {
+                    room.editTextMessage(state.selectedEvent?.root?.eventId ?: "", action.text, action.autoMarkdown)
+                    setState {
+                        copy(
+                                sendMode = SendMode.REGULAR,
+                                selectedEvent = null
+                        )
+                    }
+                    _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
+                }
+                SendMode.QUOTE   -> {
+                    val messageContent: MessageContent? =
+                            state.selectedEvent?.annotations?.editSummary?.aggregatedContent?.toModel()
+                                    ?: state.selectedEvent?.root?.content.toModel()
+                    val textMsg = messageContent?.body
+
+                    val finalText = legacyRiotQuoteText(textMsg, action.text)
+
+                    //TODO Refactor this, just temporary for quotes
+                    val parser = Parser.builder().build()
+                    val document = parser.parse(finalText)
+                    val renderer = HtmlRenderer.builder().build()
+                    val htmlText = renderer.render(document)
+                    if (TextUtils.equals(finalText, htmlText)) {
+                        room.sendTextMessage(finalText)
+                    } else {
+                        room.sendFormattedTextMessage(finalText, htmlText)
+                    }
+                    setState {
+                        copy(
+                                sendMode = SendMode.REGULAR,
+                                selectedEvent = null
+                        )
+                    }
+                    _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
+                }
+                SendMode.REPLY   -> {
+                    state.selectedEvent?.let {
+                        room.replyToMessage(it.root, action.text)
+                        setState {
+                            copy(
+                                    sendMode = SendMode.REGULAR,
+                                    selectedEvent = null
+                            )
+                        }
+                        _sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
+                    }
+
+                }
             }
         }
+        // Handle slash command
+
+    }
+
+    private fun legacyRiotQuoteText(quotedText: String?, myText: String): String {
+        val messageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray()
+        var quotedTextMsg = StringBuilder()
+        if (messageParagraphs != null) {
+            for (i in messageParagraphs.indices) {
+                if (messageParagraphs[i].trim({ it <= ' ' }) != "") {
+                    quotedTextMsg.append("> ").append(messageParagraphs[i])
+                }
+
+                if (i + 1 != messageParagraphs.size) {
+                    quotedTextMsg.append("\n\n")
+                }
+            }
+        }
+        val finalText = "$quotedTextMsg\n\n$myText"
+        return finalText
     }
 
     private fun handleShowEditHistoryReaction(action: RoomDetailActions.ShowEditHistoryAction) {
@@ -263,6 +364,34 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
         room.join(object : MatrixCallback {})
     }
 
+    private fun handleEditAction(action: RoomDetailActions.EnterEditMode) {
+        room.getTimeLineEvent(action.eventId)?.let {
+            enterEditMode(it)
+        }
+    }
+
+    private fun handleQuoteAction(action: RoomDetailActions.EnterQuoteMode) {
+        room.getTimeLineEvent(action.eventId)?.let {
+            setState {
+                copy(
+                        sendMode = SendMode.QUOTE,
+                        selectedEvent = it
+                )
+            }
+        }
+    }
+
+    private fun handleReplyAction(action: RoomDetailActions.EnterReplyMode) {
+        room.getTimeLineEvent(action.eventId)?.let {
+            setState {
+                copy(
+                        sendMode = SendMode.REPLY,
+                        selectedEvent = it
+                )
+            }
+        }
+    }
+
 
     private fun observeEventDisplayedActions() {
         // We are buffering scroll events for one second
diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewState.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewState.kt
index 8d5d9dcc..6151b425 100644
--- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewState.kt
+++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewState.kt
@@ -22,15 +22,33 @@ import com.airbnb.mvrx.Uninitialized
 import im.vector.matrix.android.api.session.room.model.RoomSummary
 import im.vector.matrix.android.api.session.room.timeline.Timeline
 import im.vector.matrix.android.api.session.room.timeline.TimelineData
+import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
 import im.vector.matrix.android.api.session.user.model.User
 
+/**
+ * Describes the current send mode:
+ * REGULAR: sends the text as a regular message
+ * QUOTE: User is currently quoting a message
+ * EDIT: User is currently editing an existing message
+ *
+ * Depending on the state the bottom toolbar will change (icons/preview/actions...)
+ */
+enum class SendMode {
+    REGULAR,
+    QUOTE,
+    EDIT,
+    REPLY
+}
+
 data class RoomDetailViewState(
         val roomId: String,
         val eventId: String?,
         val timeline: Timeline? = null,
         val asyncInviter: Async = Uninitialized,
         val asyncRoomSummary: Async = Uninitialized,
-        val asyncTimelineData: Async = Uninitialized
+        val asyncTimelineData: Async = Uninitialized,
+        val sendMode: SendMode = SendMode.REGULAR,
+        val selectedEvent: TimelineEvent? = null
 ) : MvRxState {
 
     constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId)
diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/composer/TextComposerView.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/composer/TextComposerView.kt
new file mode 100644
index 00000000..c0fc2725
--- /dev/null
+++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/composer/TextComposerView.kt
@@ -0,0 +1,116 @@
+package im.vector.riotredesign.features.home.room.detail.composer
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.ViewGroup
+import android.widget.EditText
+import android.widget.ImageButton
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.constraintlayout.widget.ConstraintLayout
+import androidx.constraintlayout.widget.ConstraintSet
+import androidx.core.view.isVisible
+import androidx.transition.AutoTransition
+import androidx.transition.Transition
+import androidx.transition.TransitionManager
+import butterknife.BindView
+import butterknife.ButterKnife
+import im.vector.riotredesign.R
+
+
+/**
+ * Encapsulate the timeline composer UX.
+ *
+ */
+class TextComposerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
+                                                 defStyleAttr: Int = 0) : ConstraintLayout(context, attrs, defStyleAttr) {
+
+    @BindView(R.id.composer_related_message_sender)
+    lateinit var composerRelatedMessageTitle: TextView
+    @BindView(R.id.composer_related_message_preview)
+    lateinit var composerRelatedMessageContent: TextView
+    @BindView(R.id.composer_related_message_avatar_view)
+    lateinit var composerRelatedMessageAvatar: ImageView
+    @BindView(R.id.composer_related_message_action_image)
+    lateinit var composerRelatedMessageActionIcon: ImageView
+    @BindView(R.id.composer_related_message_close)
+    lateinit var composerRelatedMessageCloseButton: ImageButton
+    @BindView(R.id.composerEditText)
+    lateinit var composerEditText: EditText
+    @BindView(R.id.composer_avatar_view)
+    lateinit var composerAvatarImageView: ImageView
+
+    var currentConstraintSetId: Int = -1
+
+
+    init {
+        inflate(context, R.layout.merge_composer_layout, this)
+        ButterKnife.bind(this)
+        collapse(false)
+    }
+
+
+    fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) {
+        if (currentConstraintSetId == R.layout.constraint_set_composer_layout_compact) {
+            //ignore we good
+            return
+        }
+        currentConstraintSetId = R.layout.constraint_set_composer_layout_compact
+        if (animate) {
+            val transition = AutoTransition()
+//            transition.duration = 5000
+            transition.addListener(object : Transition.TransitionListener {
+
+                override fun onTransitionEnd(transition: Transition) {
+                    transitionComplete?.invoke()
+                }
+
+                override fun onTransitionResume(transition: Transition) {}
+
+                override fun onTransitionPause(transition: Transition) {}
+
+                override fun onTransitionCancel(transition: Transition) {}
+
+                override fun onTransitionStart(transition: Transition) {}
+            }
+            )
+            TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition)
+        }
+        ConstraintSet().also {
+            it.clone(context, currentConstraintSetId)
+            it.applyTo(this)
+        }
+    }
+
+    fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) {
+        if (currentConstraintSetId == R.layout.constraint_set_composer_layout_expanded) {
+            //ignore we good
+            return
+        }
+        currentConstraintSetId = R.layout.constraint_set_composer_layout_expanded
+        if (animate) {
+            val transition = AutoTransition()
+//            transition.duration = 5000
+            transition.addListener(object : Transition.TransitionListener {
+
+                override fun onTransitionEnd(transition: Transition) {
+                    transitionComplete?.invoke()
+                }
+
+                override fun onTransitionResume(transition: Transition) {}
+
+                override fun onTransitionPause(transition: Transition) {}
+
+                override fun onTransitionCancel(transition: Transition) {}
+
+                override fun onTransitionStart(transition: Transition) {}
+            }
+            )
+            TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition)
+        }
+        ConstraintSet().also {
+            it.clone(context, currentConstraintSetId)
+            it.applyTo(this)
+        }
+    }
+}
\ No newline at end of file
diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
index a35199a0..9198f8ee 100644
--- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
+++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/action/MessageActionsViewModel.kt
@@ -21,8 +21,14 @@ import com.airbnb.mvrx.ViewModelContext
 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.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.riotredesign.core.platform.VectorViewModel
+import org.commonmark.parser.Parser
+import org.commonmark.renderer.html.HtmlRenderer
 import org.koin.android.ext.android.get
+import ru.noties.markwon.Markwon
+import ru.noties.markwon.html.HtmlPlugin
 import timber.log.Timber
 import java.text.SimpleDateFormat
 import java.util.*
@@ -31,7 +37,7 @@ import java.util.*
 data class MessageActionState(
         val userId: String,
         val senderName: String,
-        val messageBody: String,
+        val messageBody: CharSequence,
         val ts: String?,
         val senderAvatarPath: String? = null)
     : MvRxState
@@ -54,10 +60,19 @@ class MessageActionsViewModel(initialState: MessageActionState) : VectorViewMode
                 val messageContent: MessageContent? = event.annotations?.editSummary?.aggregatedContent?.toModel()
                         ?: event.root.content.toModel()
                 val originTs = event.root.originServerTs
+                var body: CharSequence = messageContent?.body ?: ""
+                if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
+                    val parser = Parser.builder().build()
+                    val document = parser.parse(messageContent.formattedBody ?: messageContent.body)
+                   // val renderer = HtmlRenderer.builder().build()
+                    body = Markwon.builder(viewModelContext.activity)
+                            .usePlugin(HtmlPlugin.create()).build().render(document)
+//                    body = renderer.render(document)
+                }
                 MessageActionState(
                         event.root.sender ?: "",
                         parcel.informationData.memberName.toString(),
-                        messageContent?.body ?: "",
+                        body,
                         dateFormat.format(Date(originTs ?: 0)),
                         currentSession.contentUrlResolver().resolveFullSize(parcel.informationData.avatarUrl)
                 )
diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/action/MessageMenuViewModel.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/action/MessageMenuViewModel.kt
index c39da484..86ddf866 100644
--- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/action/MessageMenuViewModel.kt
+++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/action/MessageMenuViewModel.kt
@@ -50,16 +50,17 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel().apply {
-                this.add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_smile, event.root.eventId))
+
+                if (event.sendState == SendState.SENDING) {
+                    //TODO add cancel?
+                    return@apply
+                }
+                //TODO is downloading attachement?
+
+                this.add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_add_reaction, event.root.eventId))
                 if (canCopy(type)) {
                     //TODO copy images? html? see ClipBoard
                     this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent.body))
                 }
 
+                if (canReply(event, messageContent)) {
+                    this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_reply, event.root.eventId))
+                }
+
+                if (canEdit(event, currentSession.sessionParams.credentials.userId)) {
+                    this.add(SimpleAction(ACTION_EDIT, R.string.edit, R.drawable.ic_edit, event.root.eventId))
+                }
+
                 if (canRedact(event, currentSession.sessionParams.credentials.userId)) {
-                    this.add(SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_material_delete, event.root.eventId))
+                    this.add(SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, event.root.eventId))
                 }
 
                 if (canQuote(event, messageContent)) {
@@ -82,9 +98,6 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel()
+            return event.root.sender == myUserId && (
+                    messageContent?.type == MessageType.MSGTYPE_TEXT
+                            || messageContent?.type == MessageType.MSGTYPE_EMOTE
+                    )
+        }
+
 
         private fun canCopy(type: String): Boolean {
             return when (type) {
@@ -187,6 +209,7 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel.filterDisplayableEvents(): List {
-    return this.filter {
-        it.isDisplayable()
+    if (!TimelineDisplayableEvents.DISPLAYABLE_TYPES.contains(root.type)) {
+        return false
     }
+    if (root.content.isNullOrEmpty()) {
+        return false
+    }
+    //Edits should be filtered out!
+    if (EventType.MESSAGE == root.type
+            && root.content.toModel()?.relatesTo?.type == RelationType.REPLACE) {
+        return false
+    }
+    return true
 }
+//
+//fun List.filterDisplayableEvents(): List {
+//    return this.filter {
+//        it.isDisplayable()
+//    }
+//}
 
 fun TimelineEvent.senderAvatar(): String? {
     // We might have no avatar when user leave, so we try to get it from prevContent
     return senderAvatar
-           ?: if (root.type == EventType.STATE_ROOM_MEMBER) {
-               root.prevContent.toModel()?.avatarUrl
-           } else {
-               null
-           }
+            ?: if (root.type == EventType.STATE_ROOM_MEMBER) {
+                root.prevContent.toModel()?.avatarUrl
+            } else {
+                null
+            }
 }
 
 fun TimelineEvent.senderName(): String? {
     // We might have no senderName when user leave, so we try to get it from prevContent
     return senderName
-           ?: if (root.type == EventType.STATE_ROOM_MEMBER) {
-               root.prevContent.toModel()?.displayName
-           } else {
-               null
-           }
+            ?: if (root.type == EventType.STATE_ROOM_MEMBER) {
+                root.prevContent.toModel()?.displayName
+            } else {
+                null
+            }
 }
 
 fun TimelineEvent.canBeMerged(): Boolean {
diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/AbsMessageItem.kt
index d560424d..0ce01a88 100644
--- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/AbsMessageItem.kt
+++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/AbsMessageItem.kt
@@ -112,11 +112,12 @@ abstract class AbsMessageItem : BaseEventItem() {
                 (holder.reactionWrapper?.children?.elementAtOrNull(index) as? ReactionButton)?.let { reactionButton ->
                     reactionButton.isVisible = true
                     reactionButton.reactedListener = reactionClickListener
-                    reactionButton.setTag(R.id.messageBottomInfo, reaction.first)
+                    reactionButton.setTag(R.id.messageBottomInfo, reaction.key)
                     idToRefInFlow.add(reactionButton.id)
-                    reactionButton.reactionString = reaction.first
-                    reactionButton.reactionCount = reaction.second
-                    reactionButton.setChecked(reaction.third)
+                    reactionButton.reactionString = reaction.key
+                    reactionButton.reactionCount = reaction.count
+                    reactionButton.setChecked(reaction.addedByMe)
+                    reactionButton.isEnabled = reaction.synced
                 }
             }
             // Just setting the view as gone will break the FlowHelper (and invisible will take too much space),
diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/MessageInformationData.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/MessageInformationData.kt
index 5cdd1f9c..e7f4aa82 100644
--- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/MessageInformationData.kt
+++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/timeline/item/MessageInformationData.kt
@@ -16,9 +16,8 @@
 
 package im.vector.riotredesign.features.home.room.detail.timeline.item
 
-import im.vector.matrix.android.api.session.room.send.SendState
-
 import android.os.Parcelable
+import im.vector.matrix.android.api.session.room.send.SendState
 import kotlinx.android.parcel.Parcelize
 
 @Parcelize
@@ -31,6 +30,15 @@ data class MessageInformationData(
         val memberName: CharSequence? = null,
         val showInformation: Boolean = true,
         /*List of reactions (emoji,count,isSelected)*/
-        var orderedReactionList: List>? = null,
+        var orderedReactionList: List? = null,
         var hasBeenEdited: Boolean = false
-) : Parcelable
\ No newline at end of file
+) : Parcelable
+
+
+@Parcelize
+data class ReactionInfoData(
+        val key: String,
+        val count: Int,
+        val addedByMe: Boolean,
+        val synced: Boolean
+) : Parcelable
diff --git a/vector/src/main/res/drawable-hdpi/ic_attach_file_white.png b/vector/src/main/res/drawable-hdpi/ic_attach_file_white.png
deleted file mode 100644
index 06f642be..00000000
Binary files a/vector/src/main/res/drawable-hdpi/ic_attach_file_white.png and /dev/null differ
diff --git a/vector/src/main/res/drawable-hdpi/ic_send_white.png b/vector/src/main/res/drawable-hdpi/ic_send_white.png
deleted file mode 100644
index f133cdbe..00000000
Binary files a/vector/src/main/res/drawable-hdpi/ic_send_white.png and /dev/null differ
diff --git a/vector/src/main/res/drawable-mdpi/ic_attach_file_white.png b/vector/src/main/res/drawable-mdpi/ic_attach_file_white.png
deleted file mode 100644
index a819f403..00000000
Binary files a/vector/src/main/res/drawable-mdpi/ic_attach_file_white.png and /dev/null differ
diff --git a/vector/src/main/res/drawable-mdpi/ic_send_white.png b/vector/src/main/res/drawable-mdpi/ic_send_white.png
deleted file mode 100644
index 34e49af7..00000000
Binary files a/vector/src/main/res/drawable-mdpi/ic_send_white.png and /dev/null differ
diff --git a/vector/src/main/res/drawable-xhdpi/ic_attach_file_white.png b/vector/src/main/res/drawable-xhdpi/ic_attach_file_white.png
deleted file mode 100644
index c572c359..00000000
Binary files a/vector/src/main/res/drawable-xhdpi/ic_attach_file_white.png and /dev/null differ
diff --git a/vector/src/main/res/drawable-xhdpi/ic_send_white.png b/vector/src/main/res/drawable-xhdpi/ic_send_white.png
deleted file mode 100644
index e5f9ba41..00000000
Binary files a/vector/src/main/res/drawable-xhdpi/ic_send_white.png and /dev/null differ
diff --git a/vector/src/main/res/drawable-xxhdpi/ic_attach_file_white.png b/vector/src/main/res/drawable-xxhdpi/ic_attach_file_white.png
deleted file mode 100644
index a5dc29f0..00000000
Binary files a/vector/src/main/res/drawable-xxhdpi/ic_attach_file_white.png and /dev/null differ
diff --git a/vector/src/main/res/drawable-xxhdpi/ic_send_white.png b/vector/src/main/res/drawable-xxhdpi/ic_send_white.png
deleted file mode 100644
index 0ba718b6..00000000
Binary files a/vector/src/main/res/drawable-xxhdpi/ic_send_white.png and /dev/null differ
diff --git a/vector/src/main/res/drawable-xxxhdpi/ic_attach_file_white.png b/vector/src/main/res/drawable-xxxhdpi/ic_attach_file_white.png
deleted file mode 100644
index 60147bc7..00000000
Binary files a/vector/src/main/res/drawable-xxxhdpi/ic_attach_file_white.png and /dev/null differ
diff --git a/vector/src/main/res/drawable-xxxhdpi/ic_send_white.png b/vector/src/main/res/drawable-xxxhdpi/ic_send_white.png
deleted file mode 100644
index f02b6453..00000000
Binary files a/vector/src/main/res/drawable-xxxhdpi/ic_send_white.png and /dev/null differ
diff --git a/vector/src/main/res/drawable/ic_add_reaction.xml b/vector/src/main/res/drawable/ic_add_reaction.xml
new file mode 100644
index 00000000..ba33bc3f
--- /dev/null
+++ b/vector/src/main/res/drawable/ic_add_reaction.xml
@@ -0,0 +1,54 @@
+
+  
+  
+  
+  
+  
+  
+
diff --git a/vector/src/main/res/drawable/ic_attachment.xml b/vector/src/main/res/drawable/ic_attachment.xml
new file mode 100644
index 00000000..e54b9302
--- /dev/null
+++ b/vector/src/main/res/drawable/ic_attachment.xml
@@ -0,0 +1,14 @@
+
+  
+
diff --git a/vector/src/main/res/drawable/ic_close_round.xml b/vector/src/main/res/drawable/ic_close_round.xml
new file mode 100644
index 00000000..413a233b
--- /dev/null
+++ b/vector/src/main/res/drawable/ic_close_round.xml
@@ -0,0 +1,20 @@
+
+  
+  
+
diff --git a/vector/src/main/res/drawable/ic_smile.xml b/vector/src/main/res/drawable/ic_delete.xml
similarity index 51%
rename from vector/src/main/res/drawable/ic_smile.xml
rename to vector/src/main/res/drawable/ic_delete.xml
index e2f3402e..b740db3c 100644
--- a/vector/src/main/res/drawable/ic_smile.xml
+++ b/vector/src/main/res/drawable/ic_delete.xml
@@ -4,31 +4,19 @@
     android:viewportWidth="22"
     android:viewportHeight="22">
   
   
-  
-  
 
diff --git a/vector/src/main/res/drawable/ic_edit.xml b/vector/src/main/res/drawable/ic_edit.xml
index ec5cf418..1ad914fc 100644
--- a/vector/src/main/res/drawable/ic_edit.xml
+++ b/vector/src/main/res/drawable/ic_edit.xml
@@ -4,19 +4,19 @@
     android:viewportWidth="21"
     android:viewportHeight="22">
   
   
 
diff --git a/vector/src/main/res/drawable/ic_corner_down_right.xml b/vector/src/main/res/drawable/ic_reply.xml
similarity index 72%
rename from vector/src/main/res/drawable/ic_corner_down_right.xml
rename to vector/src/main/res/drawable/ic_reply.xml
index 109222a3..924dda82 100644
--- a/vector/src/main/res/drawable/ic_corner_down_right.xml
+++ b/vector/src/main/res/drawable/ic_reply.xml
@@ -1,22 +1,22 @@
 
+    android:viewportHeight="13">
   
   
 
diff --git a/vector/src/main/res/drawable/ic_send.xml b/vector/src/main/res/drawable/ic_send.xml
new file mode 100644
index 00000000..d79ba7c1
--- /dev/null
+++ b/vector/src/main/res/drawable/ic_send.xml
@@ -0,0 +1,14 @@
+
+  
+
diff --git a/vector/src/main/res/layout/adapter_item_action.xml b/vector/src/main/res/layout/adapter_item_action.xml
index 5ee60d32..ce518071 100644
--- a/vector/src/main/res/layout/adapter_item_action.xml
+++ b/vector/src/main/res/layout/adapter_item_action.xml
@@ -3,7 +3,6 @@
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    tools:layout_height="50dp"
     android:clickable="true"
     android:focusable="true"
     android:foreground="?attr/selectableItemBackground"
@@ -12,7 +11,8 @@
     android:paddingLeft="@dimen/layout_horizontal_margin"
     android:paddingTop="8dp"
     android:paddingRight="@dimen/layout_horizontal_margin"
-    android:paddingBottom="8dp">
+    android:paddingBottom="8dp"
+    tools:layout_height="50dp">
 
     
+        tools:src="@drawable/ic_delete"
+        android:tint="?android:attr/textColorTertiary" />
 
     
+
+
+    
+
+    
+
+    
+
+
+    
+
+    
+
+    
+
+    
+
+
+    
+
+    
+
+    
+
+    
+
+    
+
+    
+    
+
+
\ No newline at end of file
diff --git a/vector/src/main/res/layout/constraint_set_composer_layout_expanded.xml b/vector/src/main/res/layout/constraint_set_composer_layout_expanded.xml
new file mode 100644
index 00000000..48048e62
--- /dev/null
+++ b/vector/src/main/res/layout/constraint_set_composer_layout_expanded.xml
@@ -0,0 +1,173 @@
+
+
+
+    
+
+    
+
+    
+
+    
+
+    
+
+    
+
+    
+
+
+    
+
+
+    
+
+
+    
+
+    
+
+    
+
+    
+
+
\ No newline at end of file
diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml
index d414b01b..c6627573 100644
--- a/vector/src/main/res/layout/fragment_room_detail.xml
+++ b/vector/src/main/res/layout/fragment_room_detail.xml
@@ -2,6 +2,7 @@
 
 
@@ -76,69 +77,20 @@
         android:id="@+id/recyclerView"
         android:layout_width="0dp"
         android:layout_height="0dp"
-        app:layout_constraintBottom_toTopOf="@+id/composerDivider"
+        app:layout_constraintBottom_toTopOf="@+id/composerLayout"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toBottomOf="@id/roomToolbar"
         tools:listitem="@layout/item_timeline_event_base" />
 
-    
 
-    
-
-        
-
-        
-
-        
-
-    
+        app:layout_constraintStart_toStartOf="parent" />
 
     
+
+
+    
+    
+
+    
+
+    
+
+    
+
+    
+
+    
+
+    
+
+    
+
+
+    
+
+    
+
+    
+
+    
+
+    
+
+
\ No newline at end of file
diff --git a/vector/src/main/res/values/attrs.xml b/vector/src/main/res/values/attrs.xml
index 17cb3d75..04c959ee 100644
--- a/vector/src/main/res/values/attrs.xml
+++ b/vector/src/main/res/values/attrs.xml
@@ -4,6 +4,7 @@
     
 
         
+        
 
         
         
diff --git a/vector/src/main/res/values/colors_riot.xml b/vector/src/main/res/values/colors_riot.xml
index f60d4991..395ebbce 100644
--- a/vector/src/main/res/values/colors_riot.xml
+++ b/vector/src/main/res/values/colors_riot.xml
@@ -19,6 +19,7 @@
     @color/accent_color_light
     #5EA584
     #a6d0e5
+
     #81bddb
 
     
diff --git a/vector/src/main/res/values/strings_riotX.xml b/vector/src/main/res/values/strings_riotX.xml
index 5031090a..de308c3d 100644
--- a/vector/src/main/res/values/strings_riotX.xml
+++ b/vector/src/main/res/values/strings_riotX.xml
@@ -26,10 +26,9 @@
     Last edited by %s on %s
 
 
+    Malformed event, cannot display
     Create New Room
-
     No network. Please check your Internet connection.
-
     "Change"
     "Change network"
     "Please wait…"
diff --git a/vector/src/main/res/values/theme_black.xml b/vector/src/main/res/values/theme_black.xml
index f816b9be..dabd32ae 100644
--- a/vector/src/main/res/values/theme_black.xml
+++ b/vector/src/main/res/values/theme_black.xml
@@ -27,6 +27,7 @@
         
         @color/riot_primary_background_color_black
         @color/primary_color_black
+        #FFE9EDF1
 
         @drawable/direct_chat_circle_black
     
diff --git a/vector/src/main/res/values/theme_dark.xml b/vector/src/main/res/values/theme_dark.xml
index 5c1a506e..ba78e510 100644
--- a/vector/src/main/res/values/theme_dark.xml
+++ b/vector/src/main/res/values/theme_dark.xml
@@ -21,6 +21,7 @@
         
         @color/riot_primary_background_color_dark
         @color/primary_color_dark
+        #FFE9EDF1
 
         
         #55555555
diff --git a/vector/src/main/res/values/theme_light.xml b/vector/src/main/res/values/theme_light.xml
index 68076a4b..e0b58bf0 100644
--- a/vector/src/main/res/values/theme_light.xml
+++ b/vector/src/main/res/values/theme_light.xml
@@ -23,6 +23,7 @@
         
         @color/riot_primary_background_color_light
         #FFF3F8FD
+        #FFE9EDF1
 
         
         @style/Widget.Vector.Button
diff --git a/vector/src/main/res/values/theme_status.xml b/vector/src/main/res/values/theme_status.xml
index d2874e69..9a50b88d 100644
--- a/vector/src/main/res/values/theme_status.xml
+++ b/vector/src/main/res/values/theme_status.xml
@@ -23,6 +23,7 @@
         @color/riot_primary_background_color_status
         @color/riot_primary_background_color_status
         
+        #FFE9EDF1
 
         @style/Widget.Vector.Button