Merge branch 'develop' into feature/home_rework

This commit is contained in:
Benoit Marty 2019-06-04 12:54:16 +02:00
commit 2b6eee4237
94 changed files with 2304 additions and 778 deletions

View File

@ -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'

View File

@ -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)

View File

@ -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

View File

@ -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<String>,
val localEchos: List<String>,
val lastEditTs: Long = 0
)

View File

@ -5,5 +5,6 @@ data class ReactionAggregatedSummary(
val count: Int, // 8
val addedByMe: Boolean, // true
val firstTimestamp: Long, // unix timestamp
val sourceEvents: List<String>
val sourceEvents: List<String>,
val localEchoEvents: List<String>
)

View File

@ -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)

}

View File

@ -1,6 +0,0 @@
package im.vector.matrix.android.api.session.room.model.annotation

interface RelationContent {
val type: String?
val eventId: String?
}

View File

@ -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(

View File

@ -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 {

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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?
}

View File

@ -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

View File

@ -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?

}

View File

@ -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
)

View File

@ -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<ContentAttachmentData>): Cancelable

/**
* Redacts (delete) the given event.
* @param event The event to redact
* @param reason Optional reason string
*/
fun redactEvent(event: Event, reason: String?): Cancelable

}

View File

@ -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
)
}

View File

@ -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<String> = RealmList(),
var sourceLocalEchoEvents: RealmList<String> = RealmList(),
var lastEditTs: Long = 0
) : RealmObject() {


View File

@ -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<String> = RealmList()
var sourceEvents: RealmList<String> = RealmList(),
// List of transaction ids for local echos
var sourceLocalEcho: RealmList<String> = RealmList()
) : RealmObject() {

companion object

View File

@ -48,6 +48,7 @@ internal fun EventEntity.Companion.where(realm: Realm,
}
}


internal fun EventEntity.Companion.latestEvent(realm: Realm,
roomId: String,
includedTypes: List<String> = emptyList(),

View File

@ -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()

View File

@ -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<LiveEntityObserver>(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<LiveEntityObserver>(groupSummaryUpdater, userEntityUpdater, aggregationUpdater, eventsPruner)
}



View File

@ -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<RoomSummary> by lazy {

View File

@ -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<EventRelationsAggregationTask.Params, Unit> {

data class Params(
val events: List<Pair<Event, SendState>>,
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<Unit> {
return monarchy.tryTransactionAsync { realm ->
update(realm, params.events, params.userId)
}
}

private fun update(realm: Realm, events: List<Pair<Event, SendState>>, 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<MessageContent>()
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<ReactionContent>()?.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<MessageContent>()?.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")
}
}
}

View File

@ -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<EventEntity>(monarchy) {

fun update(realm: Realm, roomId: String, events: List<Event>?) {
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> {
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<EventEntity>, updated: List<EventEntity>, deleted: List<EventEntity>) {
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
)

task.configureWith(params)
.executeBy(taskExecutor)

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}")
}
}

}

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<ReactionContent>()?.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<MessageContent>()?.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")
}
}
}

View File

@ -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)

View File

@ -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
}

}

View File

@ -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<SendEventWorker>()
.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<FindReactionEventForUndoTask.Result> {
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<UpdateQuickReactionTask.Result> {
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<RedactEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(redactWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}
}

View File

@ -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> { EventEntity.where(it, type = EventType.REDACTION) }

override fun processChanges(inserted: List<EventEntity>, updated: List<EventEntity>, deleted: List<EventEntity>) {
Timber.v("Event pruner called with ${inserted.size} insertions")
val redactionEvents = inserted
.mapNotNull { it.asDomain() }


View File

@ -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<PruneEventTask.Params, Unit> {
}

internal class DefaultPruneEventTask(
private val monarchy: Monarchy,
private val eventRelationsAggregationUpdater: EventRelationsAggregationUpdater) : PruneEventTask {
private val monarchy: Monarchy) : PruneEventTask {

override fun execute(params: PruneEventTask.Params): Try<Unit> {
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<MessageContent>()
if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) {
eventRelationsAggregationUpdater.handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm)
}
// val contentModel = ContentMapper.map(eventToPrune.content)?.toModel<MessageContent>()
// 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)
// }
}
}
}

View File

@ -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<SendEventWorker>(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<FindReactionEventForUndoTask.Result> {
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<UpdateQuickReactionTask.Result> {
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<RedactEventWorker>(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<SendEventWorker>(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<SendEventWorker>(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)
}
}
}

View File

@ -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

View File

@ -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() })
}

View File

@ -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

View File

@ -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<SendEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(sendWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
return TimelineSendEventWorkCommon.createWork<SendEventWorker>(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<RedactEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(redactWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
return TimelineSendEventWorkCommon.createWork<RedactEventWorker>(redactWorkData)
}

private fun createUploadMediaWork(event: Event, attachment: ContentAttachmentData): OneTimeWorkRequest {

View File

@ -17,24 +17,98 @@
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 != "<p>$text</p>\n"

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


fun 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)
@ -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
// <mx-reply>
// <blockquote>
// <a href="https://matrix.to/#/!somewhere:domain.com/$event:domain.com">In reply to</a>
// <a href="https://matrix.to/#/@alice:example.org">@alice:example.org</a>
// <br />
// <!-- This is where the related event's HTML would be. -->
// </blockquote>
// </mx-reply>
// This is where the reply goes.
val body = bodyForReply(eventReplied.content.toModel<MessageContent>())
val replyFallbackTemplateFormatted = """
<mx-reply><blockquote><a href="%s">${stringProvider.getString(R.string.message_reply_to_prefix)}</a><a href="%s">%s</a><br />%s</blockquote></mx-reply>%s
""".trimIndent().format(permalink, userLink, userId, body.second ?: body.first, replyText)
//
// > <@alice:example.org> This is the original body
//
// This is where the reply goes
val lines = body.first.split("\n")
val plainTextBody = StringBuffer("><${userId}>")
lines.firstOrNull()?.also { plainTextBody.append(" $it") }
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 <Plain Text, Formatted Text?> used for the fallback event representation
* in a reply message.
*/
private fun bodyForReply(content: MessageContent?): Pair<String, String?> {
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)
}
}

}

View File

@ -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<RoomAPI>()

override fun doWork(): Result {
val params = WorkerParamsFactory.fromData<RedactEventWorker.Params>(inputData)
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.failure()

if (params.eventId == null) {
return Result.failure()
}
val txID = UUID.randomUUID().toString()

val eventId = params.eventId
val result = executeRequest<SendResponse> {
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()

View File

@ -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() })
}
}

View File

@ -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 <reified W : ListenableWorker> createWork(data: Data): OneTimeWorkRequest {
return OneTimeWorkRequestBuilder<W>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(data)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}

private fun buildWorkIdentifier(roomId: String): String {
return "${roomId}_$SEND_WORK"
}
}

View File

@ -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) {

/**
* <pre>
@ -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)

View File

@ -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<String, RoomSync>) : HandlingStrategy()
@ -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()) {

View File

@ -40,7 +40,7 @@ internal class SyncModule {
}

scope(DefaultSession.SCOPE) {
RoomSyncHandler(get(), get(), get(), get(), get())
RoomSyncHandler(get(), get(), get(), get())
}

scope(DefaultSession.SCOPE) {

View File

@ -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)
}


}

View File

@ -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() {

View File

@ -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(" ")
}


View File

@ -72,9 +72,10 @@ class HomeModule {
val timelineMediaSizeProvider = TimelineMediaSizeProvider()
val colorProvider = ColorProvider(fragment.requireContext())
val timelineDateFormatter = get<TimelineDateFormatter>()
val messageItemFactory = MessageItemFactory(colorProvider, timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer, get())

val timelineItemFactory = TimelineItemFactory(
messageItemFactory = MessageItemFactory(colorProvider, timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer),
messageItemFactory = messageItemFactory,
noticeItemFactory = NoticeItemFactory(noticeEventFormatter),
defaultItemFactory = DefaultItemFactory()
)

View File

@ -23,7 +23,7 @@ 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<MediaFile>) : RoomDetailActions()
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions()
@ -35,5 +35,9 @@ sealed class 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()


}

View File

@ -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<Command>(composerEditText)
.with(CommandAutocompletePolicy())
Autocomplete.on<Command>(composerLayout.composerEditText)
.with(commandAutocompletePolicy)
.with(autocompleteCommandPresenter)
.with(elevation)
.with(backgroundDrawable)
@ -253,7 +335,7 @@ class RoomDetailFragment :
.build()

autocompleteUserPresenter.callback = this
Autocomplete.on<User>(composerEditText)
Autocomplete.on<User>(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))

View File

@ -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<RoomDetailViewState>(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<LiveEvent<Pair<Int, List<Any>>>>()
val nonBlockingPopAlert: LiveData<LiveEvent<Pair<Int, List<Any>>>>
@ -99,13 +126,15 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
// PRIVATE METHODS *****************************************************************************

private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
// Handle slash command
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)
room.sendTextMessage(action.text, autoMarkdown = action.autoMarkdown)
_sendMessageResultLiveData.postValue(LiveEvent(SendMessageResult.MessageSent))
}
is ParsedCommand.ErrorSyntax -> {
@ -165,6 +194,78 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
}
}
}
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) {
//TODO temporary implementation
@ -263,6 +364,34 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
room.join(object : MatrixCallback<Unit> {})
}

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

View File

@ -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<User> = Uninitialized,
val asyncRoomSummary: Async<RoomSummary> = Uninitialized,
val asyncTimelineData: Async<TimelineData> = Uninitialized
val asyncTimelineData: Async<TimelineData> = Uninitialized,
val sendMode: SendMode = SendMode.REGULAR,
val selectedEvent: TimelineEvent? = null
) : MvRxState {

constructor(args: RoomDetailArgs) : this(roomId = args.roomId, eventId = args.eventId)

View File

@ -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)
}
}
}

View File

@ -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)
)

View File

@ -50,16 +50,17 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
?: return null

val messageContent: MessageContent = event.root.content.toModel() ?: return null
val messageContent: MessageContent = event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.content.toModel() ?: return null
val type = messageContent.type

if (event.sendState == SendState.UNSENT) {
//Resend and Delete
return MessageMenuState(
listOf(
SimpleAction(ACTION_RESEND, R.string.resend, R.drawable.ic_corner_down_right, event.root.eventId),
SimpleAction(ACTION_RESEND, R.string.resend, R.drawable.ic_send, event.root.eventId),
//TODO delete icon
SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_material_delete, event.root.eventId)
SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, event.root.eventId)
)
)
}
@ -67,14 +68,29 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes

//TODO determine if can copy, forward, reply, quote, report?
val actions = ArrayList<SimpleAction>().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<Mes
this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, parcel.eventId))
}

if (canReply(event, messageContent)) {
this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_corner_down_right))
}
if (canShare(type)) {
if (messageContent is MessageImageContent) {
this.add(
@ -96,8 +109,6 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
//TODO
}

//TODO is uploading
//TODO is downloading

if (event.sendState == SendState.SENT) {

@ -159,6 +170,17 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
return event.root.sender == myUserId
}

private fun canEdit(event: TimelineEvent, myUserId: String): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.type != EventType.MESSAGE) return false
//TODO if user is admin or moderator
val messageContent = event.root.content.toModel<MessageContent>()
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<Mes

const val ACTION_ADD_REACTION = "add_reaction"
const val ACTION_COPY = "copy"
const val ACTION_EDIT = "edit"
const val ACTION_QUOTE = "quote"
const val ACTION_REPLY = "reply"
const val ACTION_SHARE = "share"

View File

@ -38,6 +38,7 @@ import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.core.linkify.VectorLinkify
import im.vector.riotredesign.core.resources.ColorProvider
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.core.utils.DebouncedClickListener
import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
@ -52,7 +53,8 @@ import me.gujun.android.span.span
class MessageItemFactory(private val colorProvider: ColorProvider,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val timelineDateFormatter: TimelineDateFormatter,
private val htmlRenderer: EventHtmlRenderer) {
private val htmlRenderer: EventHtmlRenderer,
private val stringProvider: StringProvider) {

fun create(event: TimelineEvent,
nextEvent: TimelineEvent?,
@ -88,7 +90,9 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
avatarUrl = avatarUrl,
memberName = formattedMemberName,
showInformation = showInformation,
orderedReactionList = event.annotations?.reactionsSummary?.map { Triple(it.key, it.count, it.addedByMe) },
orderedReactionList = event.annotations?.reactionsSummary?.map {
ReactionInfoData(it.key, it.count, it.addedByMe, it.localEchoEvents.isEmpty())
},
hasBeenEdited = hasBeenEdited
)

@ -100,11 +104,11 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
val messageContent: MessageContent =
event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.content.toModel()
?: return null
?: //Malformed content, we should echo something on screen
return DefaultItem_().text(stringProvider.getString(R.string.malformed_message))

if (messageContent.relatesTo?.type == RelationType.REPLACE) {
//TODO blank item or ignore??
// ignore this event
// ignore replace event, the targeted id is already edited
return BlankItem_()
}
// val all = event.root.toContent()

View File

@ -17,8 +17,10 @@
package im.vector.riotredesign.features.home.room.detail.timeline.helper

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.toModel
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.extensions.localDateTime

@ -42,14 +44,25 @@ object TimelineDisplayableEvents {
}

fun TimelineEvent.isDisplayable(): Boolean {
return TimelineDisplayableEvents.DISPLAYABLE_TYPES.contains(root.type) && !root.content.isNullOrEmpty()
if (!TimelineDisplayableEvents.DISPLAYABLE_TYPES.contains(root.type)) {
return false
}

fun List<TimelineEvent>.filterDisplayableEvents(): List<TimelineEvent> {
return this.filter {
it.isDisplayable()
if (root.content.isNullOrEmpty()) {
return false
}
//Edits should be filtered out!
if (EventType.MESSAGE == root.type
&& root.content.toModel<MessageContent>()?.relatesTo?.type == RelationType.REPLACE) {
return false
}
return true
}
//
//fun List<TimelineEvent>.filterDisplayableEvents(): List<TimelineEvent> {
// 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

View File

@ -112,11 +112,12 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
(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),

View File

@ -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<Triple<String,Int,Boolean>>? = null,
var orderedReactionList: List<ReactionInfoData>? = null,
var hasBeenEdited: Boolean = false
) : Parcelable


@Parcelize
data class ReactionInfoData(
val key: String,
val count: Int,
val addedByMe: Boolean,
val synced: Boolean
) : Parcelable

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 507 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 B

View File

@ -0,0 +1,54 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="22dp"
android:viewportWidth="24"
android:viewportHeight="22">
<path
android:pathData="m13.5909,20.8117c-0.8681,0.2472 -1.7837,0.3795 -2.7298,0.3795 -5.5574,0 -10.0625,-4.5627 -10.0625,-10.1912 0,-5.6284 4.5051,-10.1912 10.0625,-10.1912 5.4556,0 9.8971,4.3972 10.058,9.8831h-0.9588c-0.1605,-4.9498 -4.1729,-8.9125 -9.0992,-8.9125 -5.0281,0 -9.1042,4.1282 -9.1042,9.2206 0,5.0924 4.0761,9.2206 9.1042,9.2206 0.951,0 1.868,-0.1477 2.7298,-0.4217z"
android:strokeLineJoin="round"
android:strokeWidth="1.047619"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9e9e9e"
android:strokeLineCap="round"/>
<path
android:pathData="m14.6944,16.8235h7.6667"
android:strokeLineJoin="round"
android:strokeWidth="2.095238"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9e9e9e"
android:strokeLineCap="round"/>
<path
android:pathData="m18.5278,12.9412l-0,7.7647"
android:strokeLineJoin="round"
android:strokeWidth="2.095238"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9e9e9e"
android:strokeLineCap="round"/>
<path
android:pathData="m7.0278,12.9412s1.4375,1.9412 3.8333,1.9412c2.3958,0 3.8333,-1.9412 3.8333,-1.9412"
android:strokeLineJoin="round"
android:strokeWidth="2.095238"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9e9e9e"
android:strokeLineCap="round"/>
<path
android:pathData="m7.9861,8.0882h0.0096"
android:strokeLineJoin="round"
android:strokeWidth="2.095238"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9e9e9e"
android:strokeLineCap="round"/>
<path
android:pathData="m13.7361,8.0882h0.0096"
android:strokeLineJoin="round"
android:strokeWidth="2.095238"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9e9e9e"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="21dp"
android:height="22dp"
android:viewportWidth="21"
android:viewportHeight="22">
<path
android:pathData="M19.468,10.571l-8.73,8.753a5.693,5.693 0,0 1,-8.066 0,5.728 5.728,0 0,1 0,-8.086l8.73,-8.752a3.795,3.795 0,0 1,5.378 0,3.818 3.818,0 0,1 0,5.39L8.04,16.63a1.898,1.898 0,0 1,-2.689 0,1.91 1.91,0 0,1 0,-2.696l8.065,-8.076"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M11,11m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#979797"
android:fillType="evenOdd"/>
<path
android:pathData="M7.667,7.667L14.333,14.333M14.333,7.667L7.667,14.333"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeLineCap="round"/>
</vector>

View File

@ -4,31 +4,19 @@
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M11,11m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"
android:pathData="M3.222,1L18.778,1A2.222,2.222 0,0 1,21 3.222L21,18.778A2.222,2.222 0,0 1,18.778 21L3.222,21A2.222,2.222 0,0 1,1 18.778L1,3.222A2.222,2.222 0,0 1,3.222 1z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
<path
android:pathData="M7,13C7,13 8.5,15 11,15C13.5,15 15,13 15,13"
android:pathData="M7.667,7.667l6.666,6.666M14.333,7.667l-6.666,6.666"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeLineCap="round"/>
<path
android:pathData="M7.5,7.5m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0"
android:strokeWidth="1"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M14.5,7.5m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0"
android:strokeWidth="1"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
</vector>

View File

@ -4,19 +4,19 @@
android:viewportWidth="21"
android:viewportHeight="22">
<path
android:pathData="M9.497,3.06H2.888C1.845,3.06 1,3.93 1,5v13.576c0,1.07 0.845,1.94 1.888,1.94h13.218c1.042,0 1.888,-0.87 1.888,-1.94v-6.788"
android:pathData="M9.4969,3.0606L2.8882,3.0606C1.8454,3.0606 1,3.9289 1,5L1,18.5758C1,19.6469 1.8454,20.5152 2.8882,20.5152L16.1056,20.5152C17.1484,20.5152 17.9938,19.6469 17.9938,18.5758L17.9938,11.7879"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeLineCap="round"/>
<path
android:pathData="M16.578,1.606a1.966,1.966 0,0 1,2.832 0,2.097 2.097,0 0,1 0,2.91l-8.969,9.211 -3.776,0.97 0.944,-3.879 8.969,-9.212z"
android:pathData="M16.5776,1.6061C17.3598,0.8027 18.6278,0.8027 19.4099,1.6061C20.1921,2.4094 20.1921,3.7118 19.4099,4.5152L10.441,13.7273L6.6646,14.697L7.6087,10.8182L16.5776,1.6061Z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeLineCap="round"/>
</vector>

View File

@ -1,22 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:height="13dp"
android:viewportWidth="22"
android:viewportHeight="22">
android:viewportHeight="13">
<path
android:pathData="M14.75,8.5L21,14.75 14.75,21"
android:pathData="M5.4444,1l-4.4444,4.3636l4.4444,4.3636"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeLineCap="round"/>
<path
android:pathData="M1,1v8.75a5,5 0,0 0,5 5h15"
android:pathData="M21,11.9091L21,9.7273C21,7.3173 19.0102,5.3636 16.5556,5.3636L1,5.3636"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M20.142,11H4.586M20.142,11L1.05,20.192 4.586,11 1.05,1.808z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#03B381"
android:strokeLineCap="round"/>
</vector>

View File

@ -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">

<ImageView
android:id="@+id/action_icon"
@ -21,8 +21,8 @@
android:layout_gravity="center_vertical"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:src="@drawable/ic_material_delete"
android:tint="?android:attr/textColorSecondary" />
tools:src="@drawable/ic_delete"
android:tint="?android:attr/textColorTertiary" />

<TextView
android:id="@+id/action_title"

View File

@ -0,0 +1,164 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/composerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">

<View
android:id="@+id/related_message_backround"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?vctr_bottom_nav_background_color"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:layout_height="40dp" />

<View
android:id="@+id/related_message_background_top_separator"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?vctr_bottom_nav_background_border_color"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />

<View
android:id="@+id/related_message_background_bottom_separator"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="?vctr_bottom_nav_background_border_color"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />


<ImageView
android:id="@+id/composer_related_message_avatar_view"
android:layout_width="40dp"
android:layout_height="40dp"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toStartOf="parent"
tools:src="@tools:sample/avatars" />

<TextView
android:id="@+id/composer_related_message_sender"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textStyle="bold"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@id/composer_related_message_preview"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="@tools:sample/first_names" />

<TextView
android:id="@+id/composer_related_message_preview"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="@tools:sample/lorem/random" />

<ImageView
android:id="@+id/composer_related_message_action_image"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="38dp"
android:alpha="0"
android:tint="?android:attr/textColorTertiary"
app:layout_constraintEnd_toStartOf="parent"
app:layout_constraintTop_toBottomOf="parent"
tools:ignore="MissingConstraints"
tools:src="@drawable/ic_edit" />


<ImageButton
android:id="@+id/composer_related_message_close"
android:layout_width="22dp"
android:layout_height="22dp"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_close_round"
android:tint="@color/rosy_pink"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintStart_toEndOf="parent" />

<ImageView
android:id="@+id/composer_avatar_view"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/composerEditText"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="1"
tools:src="@tools:sample/avatars" />

<ImageButton
android:id="@+id/attachmentButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_attachment"
android:tint="?attr/colorAccent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/sendButton"
app:layout_constraintStart_toEndOf="@id/composerEditText" />

<androidx.constraintlayout.widget.Barrier
android:id="@+id/composer_preview_barrier"
android:layout_width="0dp"
android:layout_height="0dp"
app:barrierDirection="bottom"
app:barrierMargin="8dp"
app:constraint_referenced_ids="composer_related_message_preview,composer_related_message_action_image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />

<ImageButton
android:id="@+id/sendButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_send"
android:tint="?attr/colorAccent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/attachmentButton" />

<EditText
android:id="@+id/composerEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:hint="@string/room_message_placeholder_not_encrypted"
android:maxHeight="200dp"
android:minHeight="48dp"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
android:padding="16dp"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/attachmentButton"
app:layout_constraintStart_toEndOf="@+id/composer_avatar_view"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/lorem/random" />
<!--tools:text="@tools:sample/lorem/random"-->

</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,173 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/composerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">

<View
android:id="@+id/related_message_backround"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="?vctr_bottom_nav_background_color"
app:layout_constraintBottom_toBottomOf="@id/composer_preview_barrier"
app:layout_constraintTop_toTopOf="parent" />

<View
android:id="@+id/related_message_background_top_separator"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="?vctr_bottom_nav_background_border_color"
app:layout_constraintEnd_toEndOf="@id/related_message_backround"
app:layout_constraintStart_toStartOf="@+id/related_message_backround"
app:layout_constraintTop_toTopOf="@id/related_message_backround" />

<View
android:id="@+id/related_message_background_bottom_separator"
android:layout_width="0dp"
android:layout_height="1dp"
android:background="?vctr_bottom_nav_background_border_color"
app:layout_constraintBottom_toBottomOf="@id/related_message_backround"
app:layout_constraintEnd_toEndOf="@id/related_message_backround"
app:layout_constraintStart_toStartOf="@+id/related_message_backround" />

<ImageView
android:id="@+id/composer_related_message_avatar_view"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toTopOf="@id/composer_related_message_action_image"
app:layout_constraintEnd_toStartOf="@+id/composer_related_message_sender"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/composer_related_message_sender"
tools:src="@tools:sample/avatars" />

<TextView
android:id="@+id/composer_related_message_sender"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/composer_related_message_close"
app:layout_constraintStart_toEndOf="@id/composer_related_message_avatar_view"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/first_names" />

<TextView
android:id="@+id/composer_related_message_preview"
android:layout_width="0dp"
android:layout_height="match_parent"
android:ellipsize="end"
android:maxLines="2"
android:textColor="?vctr_message_text_color"
app:layout_constrainedHeight="true"
app:layout_constraintEnd_toEndOf="@id/composer_related_message_sender"
app:layout_constraintStart_toStartOf="@id/composer_related_message_sender"
app:layout_constraintTop_toBottomOf="@id/composer_related_message_sender"
tools:text="@tools:sample/lorem/random" />

<ImageView
android:id="@+id/composer_related_message_action_image"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_marginTop="6dp"
android:layout_marginBottom="38dp"
android:alpha="1"
android:tint="?android:attr/textColorTertiary"
android:visibility="visible"
app:layout_constraintEnd_toEndOf="@id/composer_related_message_avatar_view"
app:layout_constraintStart_toStartOf="@id/composer_related_message_avatar_view"
app:layout_constraintTop_toBottomOf="@id/composer_related_message_avatar_view"
tools:src="@drawable/ic_edit" />


<ImageButton
android:id="@+id/composer_related_message_close"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_close_round"
android:tint="@color/rosy_pink"
app:layout_constraintBottom_toBottomOf="@id/composer_related_message_preview"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/composer_related_message_preview" />


<ImageView
android:id="@+id/composer_avatar_view"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/composerEditText"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="1"
tools:src="@tools:sample/avatars" />


<ImageButton
android:id="@+id/attachmentButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_attachment"
android:tint="?attr/colorAccent"
app:layout_constraintEnd_toStartOf="@+id/sendButton"
app:layout_constraintTop_toBottomOf="parent" />

<androidx.constraintlayout.widget.Barrier
android:id="@+id/composer_preview_barrier"
android:layout_width="0dp"
android:layout_height="0dp"
app:barrierDirection="bottom"
app:barrierMargin="8dp"
app:constraint_referenced_ids="composer_related_message_preview,composer_related_message_action_image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />

<ImageButton
android:id="@+id/sendButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_send"
android:tint="?attr/colorAccent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/composerEditText"
app:layout_constraintTop_toBottomOf="@id/composer_preview_barrier"
app:layout_constraintVertical_bias="1" />

<EditText
android:id="@+id/composerEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:hint="@string/room_message_placeholder_not_encrypted"
android:minHeight="48dp"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
android:padding="16dp"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/sendButton"
app:layout_constraintStart_toEndOf="@id/composer_avatar_view"
app:layout_constraintTop_toBottomOf="@id/composer_preview_barrier"
tools:text="@tools:sample/lorem" />

</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -2,6 +2,7 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/rootConstraintLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">

@ -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" />

<View
android:id="@+id/composerDivider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?vctr_list_divider_color"
app:layout_constraintBottom_toTopOf="@+id/composerLayout" />

<RelativeLayout
<im.vector.riotredesign.features.home.room.detail.composer.TextComposerView
android:id="@+id/composerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">

<ImageButton
android:id="@+id/attachmentButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/sendButton"
android:layout_toLeftOf="@id/sendButton"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_attach_file_white"
android:tint="?attr/colorAccent" />

<ImageButton
android:id="@+id/sendButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_alignParentEnd="true"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_send_white"
android:tint="?attr/colorAccent" />

<EditText
android:id="@+id/composerEditText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentLeft="true"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/attachmentButton"
android:layout_toLeftOf="@id/attachmentButton"
android:background="@android:color/transparent"
android:gravity="center_vertical"
android:hint="@string/room_message_placeholder_not_encrypted"
android:inputType="textCapSentences|textMultiLine"
android:minHeight="48dp"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
android:padding="16dp"
android:textSize="14sp" />

</RelativeLayout>
app:layout_constraintStart_toStartOf="parent" />

<im.vector.riotredesign.features.invite.VectorInviteView
android:id="@+id/inviteView"

View File

@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:constraintSet="@layout/constraint_set_composer_layout_compact"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">

<!-- ========================
/!\ Constraints for this layout are defined in external layout files that are used as constraint set for animation.
/!\ These 3 files must be modified to stay coherent!
======================== -->
<View
android:id="@+id/related_message_backround"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?vctr_bottom_nav_background_color"
tools:ignore="MissingConstraints" />

<View
android:id="@+id/related_message_background_top_separator"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?vctr_bottom_nav_background_border_color"
tools:ignore="MissingConstraints" />

<View
android:id="@+id/related_message_background_bottom_separator"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?vctr_bottom_nav_background_border_color"
tools:ignore="MissingConstraints" />

<ImageView
android:id="@+id/composer_related_message_avatar_view"
android:layout_width="0dp"
android:layout_height="0dp"
tools:ignore="MissingConstraints"
tools:src="@tools:sample/avatars" />

<TextView
android:id="@+id/composer_related_message_sender"
android:layout_width="0dp"
android:layout_height="0dp"
android:textStyle="bold"
tools:ignore="MissingConstraints"
tools:text="@tools:sample/first_names"
tools:visibility="gone" />

<TextView
android:id="@+id/composer_related_message_preview"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="3"
android:textColor="?vctr_message_text_color"
tools:ignore="MissingConstraints"
tools:text="@tools:sample/lorem"
tools:visibility="gone" />

<ImageView
android:id="@+id/composer_related_message_action_image"
android:layout_width="0dp"
android:layout_height="0dp"
android:tint="?android:attr/textColorTertiary"
tools:ignore="MissingConstraints" />

<ImageButton
android:id="@+id/composer_related_message_close"
android:layout_width="22dp"
android:layout_height="22dp"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_close_round"
android:tint="@color/rosy_pink"
tools:ignore="MissingConstraints" />


<ImageView
android:id="@+id/composer_avatar_view"
android:layout_width="0dp"
android:layout_height="0dp"
tools:ignore="MissingConstraints"
tools:src="@tools:sample/avatars" />

<ImageButton
android:id="@+id/attachmentButton"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_attachment"
android:tint="?attr/colorAccent"
tools:ignore="MissingConstraints" />

<androidx.constraintlayout.widget.Barrier
android:id="@+id/composer_preview_barrier"
android:layout_width="0dp"
android:layout_height="0dp"
app:barrierDirection="bottom"
app:barrierMargin="8dp"
app:constraint_referenced_ids="composer_related_message_preview,composer_related_message_action_image"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />

<ImageButton
android:id="@+id/sendButton"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_send"
android:tint="?attr/colorAccent"
tools:ignore="MissingConstraints" />

<EditText
android:id="@+id/composerEditText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@android:color/transparent"
android:hint="@string/room_message_placeholder_not_encrypted"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
android:padding="8dp"
android:textColor="?vctr_message_text_color"
android:textSize="14sp"
tools:ignore="MissingConstraints" />

</merge>

View File

@ -4,6 +4,7 @@
<declare-styleable name="VectorStyles">

<attr name="vctr_bottom_nav_background_color" format="color" />
<attr name="vctr_bottom_nav_background_border_color" format="color" />

<!-- waiting view background -->
<attr name="vctr_waiting_background_color" format="color" />

View File

@ -19,6 +19,7 @@
<color name="tab_rooms">@color/accent_color_light</color>
<color name="tab_rooms_secondary">#5EA584</color>
<color name="tab_groups">#a6d0e5</color>

<color name="tab_groups_secondary">#81bddb</color>

<!-- color of the direct chat avatar ring (it's 50% of color accent) -->

View File

@ -26,10 +26,9 @@
<string name="last_edited_info_message">Last edited by %s on %s</string>


<string name="malformed_message">Malformed event, cannot display</string>
<string name="create_new_room">Create New Room</string>

<string name="error_no_network">No network. Please check your Internet connection.</string>

<string name="action_change">"Change"</string>
<string name="change_room_directory_network">"Change network"</string>
<string name="please_wait">"Please wait…"</string>

View File

@ -27,6 +27,7 @@
<!-- activities background -->
<item name="android:windowBackground">@color/riot_primary_background_color_black</item>
<item name="vctr_bottom_nav_background_color">@color/primary_color_black</item>
<item name="vctr_bottom_nav_background_border_color">#FFE9EDF1</item>

<item name="vctr_direct_chat_circle">@drawable/direct_chat_circle_black</item>
</style>

View File

@ -21,6 +21,7 @@
<!-- default background color -->
<item name="android:colorBackground">@color/riot_primary_background_color_dark</item>
<item name="vctr_bottom_nav_background_color">@color/primary_color_dark</item>
<item name="vctr_bottom_nav_background_border_color">#FFE9EDF1</item>

<!-- waiting view background -->
<item name="vctr_waiting_background_color">#55555555</item>

View File

@ -23,6 +23,7 @@
<!-- default background color -->
<item name="android:colorBackground">@color/riot_primary_background_color_light</item>
<item name="vctr_bottom_nav_background_color">#FFF3F8FD</item>
<item name="vctr_bottom_nav_background_border_color">#FFE9EDF1</item>

<!-- default button -->
<item name="android:buttonStyle">@style/Widget.Vector.Button</item>

View File

@ -23,6 +23,7 @@
<item name="android:colorBackground">@color/riot_primary_background_color_status</item>
<item name="vctr_bottom_nav_background_color">@color/riot_primary_background_color_status
</item>
<item name="vctr_bottom_nav_background_border_color">#FFE9EDF1</item>

<item name="buttonStyle">@style/Widget.Vector.Button</item>