Merge pull request #137 from vector-im/feature/aggregations_relations

Feature/aggregations relations
This commit is contained in:
Valere 2019-05-16 16:22:47 +02:00 committed by GitHub
commit e27367e3f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 764 additions and 160 deletions

View File

@ -20,6 +20,7 @@ import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.InstrumentedTest import im.vector.matrix.android.InstrumentedTest
import im.vector.matrix.android.api.session.room.timeline.Timeline 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.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.session.room.EventRelationExtractor
import im.vector.matrix.android.internal.session.room.members.SenderRoomMemberExtractor import im.vector.matrix.android.internal.session.room.members.SenderRoomMemberExtractor
import im.vector.matrix.android.internal.session.room.timeline.DefaultTimeline import im.vector.matrix.android.internal.session.room.timeline.DefaultTimeline
import im.vector.matrix.android.internal.session.room.timeline.TimelineEventFactory import im.vector.matrix.android.internal.session.room.timeline.TimelineEventFactory
@ -58,8 +59,16 @@ internal class TimelineTest : InstrumentedTest {
val paginationTask = FakePaginationTask(tokenChunkEventPersistor) val paginationTask = FakePaginationTask(tokenChunkEventPersistor)
val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor) val getContextOfEventTask = FakeGetContextOfEventTask(tokenChunkEventPersistor)
val roomMemberExtractor = SenderRoomMemberExtractor(ROOM_ID) val roomMemberExtractor = SenderRoomMemberExtractor(ROOM_ID)
val timelineEventFactory = TimelineEventFactory(roomMemberExtractor) val timelineEventFactory = TimelineEventFactory(roomMemberExtractor, EventRelationExtractor())
return DefaultTimeline(ROOM_ID, initialEventId, monarchy.realmConfiguration, taskExecutor, getContextOfEventTask, timelineEventFactory, paginationTask, null) return DefaultTimeline(
ROOM_ID,
initialEventId,
monarchy.realmConfiguration,
taskExecutor,
getContextOfEventTask,
timelineEventFactory,
paginationTask,
null)
} }


@Test @Test

View File

@ -0,0 +1,42 @@
/*
* 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.events.model

import com.squareup.moshi.JsonClass

/**
* <code>
* {
* "chunk": [
* {
* "type": "m.reaction",
* "key": "👍",
* "count": 3
* }
* ],
* "limited": false,
* "count": 1
* },
* </code>
*/

@JsonClass(generateAdapter = true)
data class AggregatedAnnotation (
override val limited: Boolean? = false,
override val count: Int? = 0,
val chunk: List<RelationChunkInfo>? = null

) : UnsignedRelationInfo

View File

@ -0,0 +1,53 @@
/*
* 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.events.model

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

/**
* <code>
* {
* "m.annotation": {
* "chunk": [
* {
* "type": "m.reaction",
* "key": "👍",
* "count": 3
* }
* ],
* "limited": false,
* "count": 1
* },
* "m.reference": {
* "chunk": [
* {
* "type": "m.room.message",
* "event_id": "$some_event_id"
* }
* ],
* "limited": false,
* "count": 1
* }
* }
* </code>
*/

@JsonClass(generateAdapter = true)
data class AggregatedRelations(
@Json(name = "m.annotation") val annotations: AggregatedAnnotation? = null,
@Json(name = "m.reference") val references: DefaultUnsignedRelationInfo? = null
)

View File

@ -0,0 +1,26 @@
/*
* 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.events.model

import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class DefaultUnsignedRelationInfo(
override val limited: Boolean? = false,
override val count: Int? = 0,
val chunk: List<Map<String, Any>>? = null

) : UnsignedRelationInfo

View File

@ -65,6 +65,11 @@ object EventType {
const val CALL_ANSWER = "m.call.answer" const val CALL_ANSWER = "m.call.answer"
const val CALL_HANGUP = "m.call.hangup" const val CALL_HANGUP = "m.call.hangup"


// Relation Events

const val REACTION = "m.reaction"


private val STATE_EVENTS = listOf( private val STATE_EVENTS = listOf(
STATE_ROOM_NAME, STATE_ROOM_NAME,
STATE_ROOM_TOPIC, STATE_ROOM_TOPIC,

View File

@ -0,0 +1,35 @@
/*
* 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.events.model

import com.squareup.moshi.JsonClass

/**
* <code>
* {
* "type": "m.reaction",
* "key": "👍",
* "count": 3
* }
* </code>
*/

@JsonClass(generateAdapter = true)
data class RelationChunkInfo(
val type: String,
val key: String,
val count: Int
)

View File

@ -0,0 +1,31 @@
/*
* 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.events.model


/**
* Constants defining known event relation types from Matrix specifications.
*/
object RelationType {

/** Lets you define an event which annotates an existing event.*/
const val ANNOTATION = "m.annotation"
/** Lets you define an event which replaces an existing event.*/
const val REPLACE = "m.replace"
/** ets you define an event which references an existing event.*/
const val REFERENCE = "m.reference"

}

View File

@ -24,5 +24,6 @@ data class UnsignedData(
@Json(name = "age") val age: Long?, @Json(name = "age") val age: Long?,
@Json(name = "redacted_because") val redactedEvent: Event? = null, @Json(name = "redacted_because") val redactedEvent: Event? = null,
@Json(name = "transaction_id") val transactionId: String? = null, @Json(name = "transaction_id") val transactionId: String? = null,
@Json(name = "prev_content") val prevContent: Map<String, Any>? = null @Json(name = "prev_content") val prevContent: Map<String, Any>? = null,
@Json(name = "m.relations") val relations: AggregatedRelations?
) )

View File

@ -0,0 +1,22 @@
/*
* 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.events.model


interface UnsignedRelationInfo {
val limited : Boolean?
val count: Int?
}

View File

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


data class EventAnnotationsSummary(
var eventId: String,
var reactionsSummary: List<ReactionAggregatedSummary>
)

View File

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

data class ReactionAggregatedSummary(
val key: String, // "👍"
val count: Int, // 8
val addedByMe: Boolean, // true
val firstTimestamp: Long, // unix timestamp
val sourceEvents: List<String>
)

View File

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

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class ReactionContent(
@Json(name = "m.relates_to") val relatesTo: ReactionInfo? = null
)

View File

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

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class ReactionInfo(
@Json(name = "rel_type") override val type: String,
@Json(name = "event_id") override val eventId: String,
val key: String
) : RelationContent

View File

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

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

View File

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

import com.squareup.moshi.Json

data class RelationDefaultContent(
@Json(name = "rel_type") override val type: String,
@Json(name = "event_id") override val eventId: String
) : RelationContent

View File

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


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


@ -32,7 +33,8 @@ data class TimelineEvent(
val displayIndex: Int, val displayIndex: Int,
val senderName: String?, val senderName: String?,
val senderAvatar: String?, val senderAvatar: String?,
val sendState: SendState val sendState: SendState,
val annotations: EventAnnotationsSummary? = null
) { ) {


val metadata = HashMap<String, Any>() val metadata = HashMap<String, Any>()

View File

@ -0,0 +1,26 @@
package im.vector.matrix.android.internal.database.mapper

import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.model.ReactionAggregatedSummary
import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity

internal object EventAnnotationsSummaryMapper {
fun map(annotationsSummary: EventAnnotationsSummaryEntity): EventAnnotationsSummary {
return EventAnnotationsSummary(
eventId = annotationsSummary.eventId,
reactionsSummary = annotationsSummary.reactionsSummary.toList().map {
ReactionAggregatedSummary(
it.key,
it.count,
it.addedByMe,
it.firstTimestamp,
it.sourceEvents.toList()
)
}
)
}
}

internal fun EventAnnotationsSummaryEntity.asDomain(): EventAnnotationsSummary {
return EventAnnotationsSummaryMapper.map(this)
}

View File

@ -19,12 +19,15 @@ package im.vector.matrix.android.internal.database.mapper
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.UnsignedData import im.vector.matrix.android.api.session.events.model.UnsignedData
import im.vector.matrix.android.internal.database.model.EventEntity import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.di.MoshiProvider




internal object EventMapper { internal object EventMapper {




fun map(event: Event, roomId: String): EventEntity { fun map(event: Event, roomId: String): EventEntity {
val uds = if (event.unsignedData == null) null
else MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(event.unsignedData)
val eventEntity = EventEntity() val eventEntity = EventEntity()
eventEntity.eventId = event.eventId ?: "" eventEntity.eventId = event.eventId ?: ""
eventEntity.roomId = event.roomId ?: roomId eventEntity.roomId = event.roomId ?: roomId
@ -37,10 +40,14 @@ internal object EventMapper {
eventEntity.originServerTs = event.originServerTs eventEntity.originServerTs = event.originServerTs
eventEntity.redacts = event.redacts eventEntity.redacts = event.redacts
eventEntity.age = event.unsignedData?.age ?: event.originServerTs eventEntity.age = event.unsignedData?.age ?: event.originServerTs
eventEntity.unsignedData = uds
return eventEntity return eventEntity
} }


fun map(eventEntity: EventEntity): Event { fun map(eventEntity: EventEntity): Event {
//TODO proxy the event to only parse unsigned data when accessed?
var ud = if (eventEntity.unsignedData.isNullOrBlank()) null
else MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).fromJson(eventEntity.unsignedData)
return Event( return Event(
type = eventEntity.type, type = eventEntity.type,
eventId = eventEntity.eventId, eventId = eventEntity.eventId,
@ -50,7 +57,7 @@ internal object EventMapper {
sender = eventEntity.sender, sender = eventEntity.sender,
stateKey = eventEntity.stateKey, stateKey = eventEntity.stateKey,
roomId = eventEntity.roomId, roomId = eventEntity.roomId,
unsignedData = UnsignedData(eventEntity.age), unsignedData = ud,
redacts = eventEntity.redacts redacts = eventEntity.redacts
) )
} }

View File

@ -0,0 +1,32 @@
/*
* 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.database.model

import io.realm.RealmList
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey


internal open class EventAnnotationsSummaryEntity(
@PrimaryKey
var eventId: String = "",
var roomId: String? = null,
var reactionsSummary: RealmList<ReactionAggregatedSummaryEntity> = RealmList()
) : RealmObject() {

companion object

}

View File

@ -36,6 +36,7 @@ internal open class EventEntity(@PrimaryKey var localId: String = UUID.randomUUI
var originServerTs: Long? = null, var originServerTs: Long? = null,
@Index var sender: String? = null, @Index var sender: String? = null,
var age: Long? = 0, var age: Long? = 0,
var unsignedData: String? = null,
var redacts: String? = null, var redacts: String? = null,
@Index var stateIndex: Int = 0, @Index var stateIndex: Int = 0,
@Index var displayIndex: Int = 0, @Index var displayIndex: Int = 0,

View File

@ -0,0 +1,24 @@
package im.vector.matrix.android.internal.database.model

import io.realm.RealmList
import io.realm.RealmObject

/**
* Aggregated Summary of a reaction.
*/
internal open class ReactionAggregatedSummaryEntity(
// The reaction String 😀
var key: String = "",
// Number of time this reaction was selected
var count: Int = 0,
// Did the current user sent this reaction
var addedByMe: Boolean = false,
// 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()
) : RealmObject() {

companion object

}

View File

@ -33,6 +33,8 @@ import io.realm.annotations.RealmModule
RoomSummaryEntity::class, RoomSummaryEntity::class,
RoomTagEntity::class, RoomTagEntity::class,
SyncEntity::class, SyncEntity::class,
UserEntity::class UserEntity::class,
EventAnnotationsSummaryEntity::class,
ReactionAggregatedSummaryEntity::class
]) ])
internal class SessionRealmModule internal class SessionRealmModule

View File

@ -0,0 +1,27 @@
package im.vector.matrix.android.internal.database.query

import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity
import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntityFields
import io.realm.Realm
import io.realm.RealmQuery
import io.realm.kotlin.where

internal fun EventAnnotationsSummaryEntity.Companion.where(realm: Realm, eventId: String): RealmQuery<EventAnnotationsSummaryEntity> {
val query = realm.where<EventAnnotationsSummaryEntity>()
query.equalTo(EventAnnotationsSummaryEntityFields.EVENT_ID, eventId)
return query
}

internal fun EventAnnotationsSummaryEntity.Companion.whereInRoom(realm: Realm, roomId: String?): RealmQuery<EventAnnotationsSummaryEntity> {
val query = realm.where<EventAnnotationsSummaryEntity>()
if (roomId != null) {
query.equalTo(EventAnnotationsSummaryEntityFields.ROOM_ID, roomId)
}
return query
}


internal fun EventAnnotationsSummaryEntity.Companion.create(realm: Realm, eventId: String): EventAnnotationsSummaryEntity {
val obj = realm.createObject(EventAnnotationsSummaryEntity::class.java, eventId)
return obj
}

View File

@ -17,6 +17,8 @@
package im.vector.matrix.android.internal.di package im.vector.matrix.android.internal.di


import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.model.annotation.RelationContent
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageDefaultContent import im.vector.matrix.android.api.session.room.model.message.MessageDefaultContent

View File

@ -34,6 +34,7 @@ import im.vector.matrix.android.internal.session.filter.*
import im.vector.matrix.android.internal.session.group.DefaultGroupService import im.vector.matrix.android.internal.session.group.DefaultGroupService
import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater
import im.vector.matrix.android.internal.session.room.DefaultRoomService import im.vector.matrix.android.internal.session.room.DefaultRoomService
import im.vector.matrix.android.internal.session.room.EventRelationsAggregationUpdater
import im.vector.matrix.android.internal.session.room.RoomAvatarResolver import im.vector.matrix.android.internal.session.room.RoomAvatarResolver
import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater import im.vector.matrix.android.internal.session.room.RoomSummaryUpdater
import im.vector.matrix.android.internal.session.room.members.RoomDisplayNameResolver import im.vector.matrix.android.internal.session.room.members.RoomDisplayNameResolver
@ -102,6 +103,10 @@ internal class SessionModule(private val sessionParams: SessionParams) {
RoomSummaryUpdater(get(), get(), get()) RoomSummaryUpdater(get(), get(), get())
} }


scope(DefaultSession.SCOPE) {
EventRelationsAggregationUpdater(get())
}

scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
DefaultRoomService(get(), get(), get(), get()) as RoomService DefaultRoomService(get(), get(), get(), get()) as RoomService
} }

View File

@ -0,0 +1,16 @@
package im.vector.matrix.android.internal.session.room

import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.query.where
import io.realm.Realm


internal class EventRelationExtractor {

fun extractFrom(event: EventEntity, realm: Realm = event.realm): EventAnnotationsSummary? {
return EventAnnotationsSummaryEntity.where(realm, event.eventId).findFirst()?.asDomain()
}
}

View File

@ -0,0 +1,84 @@
package im.vector.matrix.android.internal.session.room

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.internal.database.model.EventAnnotationsSummaryEntity
import im.vector.matrix.android.internal.database.model.ReactionAggregatedSummaryEntity
import im.vector.matrix.android.internal.database.query.create
import im.vector.matrix.android.internal.database.query.where
import io.realm.Realm
import timber.log.Timber


internal class EventRelationsAggregationUpdater(private val credentials: Credentials) {

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 -> {
event.unsignedData?.relations?.annotations?.let {
Timber.v("###REACTION Agreggation in room $roomId for event ${event.eventId}")
handleInitialAggregatedRelations(event, roomId, it, realm)
}
//TODO message edits
}
}
}
}

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) {
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.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(eventId)
sum.addedByMe = sum.addedByMe || (credentials.userId == event.sender)
}
}

}
}
}
}

View File

@ -46,7 +46,7 @@ internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask,


fun instantiate(roomId: String): Room { fun instantiate(roomId: String): Room {
val roomMemberExtractor = SenderRoomMemberExtractor(roomId) val roomMemberExtractor = SenderRoomMemberExtractor(roomId)
val timelineEventFactory = TimelineEventFactory(roomMemberExtractor) val timelineEventFactory = TimelineEventFactory(roomMemberExtractor, EventRelationExtractor())
val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask) val timelineService = DefaultTimelineService(roomId, monarchy, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask)
val sendService = DefaultSendService(roomId, eventFactory, monarchy) val sendService = DefaultSendService(roomId, eventFactory, monarchy)
val stateService = DefaultStateService(roomId, sendStateTask, taskExecutor) val stateService = DefaultStateService(roomId, sendStateTask, taskExecutor)

View File

@ -51,7 +51,7 @@ class RoomModule {
} }


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


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {

View File

@ -27,29 +27,22 @@ 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.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.util.CancelableBag import im.vector.matrix.android.api.util.CancelableBag
import im.vector.matrix.android.api.util.addTo import im.vector.matrix.android.api.util.addTo
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.ChunkEntityFields import im.vector.matrix.android.internal.database.model.*
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.EventEntityFields
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.query.findIncludingEvent import im.vector.matrix.android.internal.database.query.findIncludingEvent
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.database.query.whereInRoom
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith import im.vector.matrix.android.internal.task.configureWith
import im.vector.matrix.android.internal.util.Debouncer import im.vector.matrix.android.internal.util.Debouncer
import io.realm.OrderedCollectionChangeSet import io.realm.*
import io.realm.OrderedRealmCollectionChangeListener
import io.realm.Realm
import io.realm.RealmConfiguration
import io.realm.RealmQuery
import io.realm.RealmResults
import io.realm.Sort
import timber.log.Timber import timber.log.Timber
import java.util.* import java.util.*
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import kotlin.collections.ArrayList import kotlin.collections.ArrayList
import kotlin.collections.HashMap




private const val INITIAL_LOAD_SIZE = 20 private const val INITIAL_LOAD_SIZE = 20
@ -92,19 +85,23 @@ internal class DefaultTimeline(
private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN private var nextDisplayIndex: Int = DISPLAY_INDEX_UNKNOWN
private val isLive = initialEventId == null private val isLive = initialEventId == null
private val builtEvents = Collections.synchronizedList<TimelineEvent>(ArrayList()) private val builtEvents = Collections.synchronizedList<TimelineEvent>(ArrayList())
private val builtEventsIdMap = Collections.synchronizedMap(HashMap<String, Int>())
private val backwardsPaginationState = AtomicReference(PaginationState()) private val backwardsPaginationState = AtomicReference(PaginationState())
private val forwardsPaginationState = AtomicReference(PaginationState()) private val forwardsPaginationState = AtomicReference(PaginationState())




private lateinit var eventRelations: RealmResults<EventAnnotationsSummaryEntity>

private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<EventEntity>> { _, changeSet -> private val eventsChangeListener = OrderedRealmCollectionChangeListener<RealmResults<EventEntity>> { _, changeSet ->
if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL ) { if (changeSet.state == OrderedCollectionChangeSet.State.INITIAL) {
handleInitialLoad() handleInitialLoad()
} else { } else {
// If changeSet has deletion we are having a gap, so we clear everything // If changeSet has deletion we are having a gap, so we clear everything
if(changeSet.deletionRanges.isNotEmpty()){ if (changeSet.deletionRanges.isNotEmpty()) {
prevDisplayIndex = DISPLAY_INDEX_UNKNOWN prevDisplayIndex = DISPLAY_INDEX_UNKNOWN
nextDisplayIndex = DISPLAY_INDEX_UNKNOWN nextDisplayIndex = DISPLAY_INDEX_UNKNOWN
builtEvents.clear() builtEvents.clear()
builtEventsIdMap.clear()
timelineEventFactory.clear() timelineEventFactory.clear()
} }
changeSet.insertionRanges.forEach { range -> changeSet.insertionRanges.forEach { range ->
@ -130,6 +127,38 @@ internal class DefaultTimeline(
} }
} }


private val relationsListener = OrderedRealmCollectionChangeListener<RealmResults<EventAnnotationsSummaryEntity>> { collection, changeSet ->

var hasChange = false

(changeSet.insertions + changeSet.changes).forEach {
val eventRelations = collection[it]
if (eventRelations != null) {
builtEventsIdMap[eventRelations.eventId]?.let { builtIndex ->
//Update the relation of existing event
builtEvents[builtIndex]?.let { te ->
builtEvents[builtIndex] = te.copy(annotations = eventRelations.asDomain())
hasChange = true
}
}
}
}
changeSet.deletions?.forEach {
val eventRelations = collection[it]
if (eventRelations != null) {
builtEventsIdMap[eventRelations.eventId]?.let { builtIndex ->
//Update the relation of existing event
builtEvents[builtIndex]?.let { te ->
builtEvents[builtIndex] = te.copy(annotations = null)
hasChange = true
}
}
}
}
if (hasChange)
postSnapshot()
}

// Public methods ****************************************************************************** // Public methods ******************************************************************************


override fun paginate(direction: Timeline.Direction, count: Int) { override fun paginate(direction: Timeline.Direction, count: Int) {
@ -146,6 +175,7 @@ internal class DefaultTimeline(
} }
} }



override fun start() { override fun start() {
if (isStarted.compareAndSet(false, true)) { if (isStarted.compareAndSet(false, true)) {
Timber.v("Start timeline for roomId: $roomId and eventId: $initialEventId") Timber.v("Start timeline for roomId: $roomId and eventId: $initialEventId")
@ -171,6 +201,10 @@ internal class DefaultTimeline(
.also { it.addChangeListener(eventsChangeListener) } .also { it.addChangeListener(eventsChangeListener) }


isReady.set(true) isReady.set(true)

eventRelations = EventAnnotationsSummaryEntity.whereInRoom(realm, roomId)
.findAllAsync()
.also { it.addChangeListener(relationsListener) }
} }
} }
} }
@ -242,6 +276,7 @@ internal class DefaultTimeline(
} else { } else {
updatePaginationState(direction) { it.copy(isPaginating = false, requestedCount = 0) } updatePaginationState(direction) { it.copy(isPaginating = false, requestedCount = 0) }
} }

return !shouldFetchMore return !shouldFetchMore
} }


@ -266,14 +301,14 @@ internal class DefaultTimeline(


private fun getPaginationState(direction: Timeline.Direction): PaginationState { private fun getPaginationState(direction: Timeline.Direction): PaginationState {
return when (direction) { return when (direction) {
Timeline.Direction.FORWARDS -> forwardsPaginationState.get() Timeline.Direction.FORWARDS -> forwardsPaginationState.get()
Timeline.Direction.BACKWARDS -> backwardsPaginationState.get() Timeline.Direction.BACKWARDS -> backwardsPaginationState.get()
} }
} }


private fun updatePaginationState(direction: Timeline.Direction, update: (PaginationState) -> PaginationState) { private fun updatePaginationState(direction: Timeline.Direction, update: (PaginationState) -> PaginationState) {
val stateReference = when (direction) { val stateReference = when (direction) {
Timeline.Direction.FORWARDS -> forwardsPaginationState Timeline.Direction.FORWARDS -> forwardsPaginationState
Timeline.Direction.BACKWARDS -> backwardsPaginationState Timeline.Direction.BACKWARDS -> backwardsPaginationState
} }
val currentValue = stateReference.get() val currentValue = stateReference.get()
@ -316,9 +351,9 @@ internal class DefaultTimeline(
private fun executePaginationTask(direction: Timeline.Direction, limit: Int) { private fun executePaginationTask(direction: Timeline.Direction, limit: Int) {
val token = getTokenLive(direction) ?: return val token = getTokenLive(direction) ?: return
val params = PaginationTask.Params(roomId = roomId, val params = PaginationTask.Params(roomId = roomId,
from = token, from = token,
direction = direction.toPaginationDirection(), direction = direction.toPaginationDirection(),
limit = limit) limit = limit)


Timber.v("Should fetch $limit items $direction") Timber.v("Should fetch $limit items $direction")
paginationTask.configureWith(params) paginationTask.configureWith(params)
@ -384,6 +419,7 @@ internal class DefaultTimeline(
val timelineEvent = timelineEventFactory.create(eventEntity) val timelineEvent = timelineEventFactory.create(eventEntity)
val position = if (direction == Timeline.Direction.FORWARDS) 0 else builtEvents.size val position = if (direction == Timeline.Direction.FORWARDS) 0 else builtEvents.size
builtEvents.add(position, timelineEvent) builtEvents.add(position, timelineEvent)
builtEventsIdMap[eventEntity.eventId] = position
} }
Timber.v("Built ${offsetResults.size} items from db") Timber.v("Built ${offsetResults.size} items from db")
return offsetResults.size return offsetResults.size

View File

@ -19,10 +19,13 @@ package im.vector.matrix.android.internal.session.room.timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.database.mapper.asDomain 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.model.EventEntity
import im.vector.matrix.android.internal.session.room.EventRelationExtractor
import im.vector.matrix.android.internal.session.room.members.SenderRoomMemberExtractor import im.vector.matrix.android.internal.session.room.members.SenderRoomMemberExtractor
import io.realm.Realm import io.realm.Realm


internal class TimelineEventFactory(private val roomMemberExtractor: SenderRoomMemberExtractor) { internal class TimelineEventFactory(
private val roomMemberExtractor: SenderRoomMemberExtractor,
private val relationExtractor: EventRelationExtractor) {


private val cached = mutableMapOf<String, SenderData>() private val cached = mutableMapOf<String, SenderData>()


@ -30,20 +33,22 @@ internal class TimelineEventFactory(private val roomMemberExtractor: SenderRoomM
val sender = eventEntity.sender val sender = eventEntity.sender
val cacheKey = sender + eventEntity.stateIndex val cacheKey = sender + eventEntity.stateIndex
val senderData = cached.getOrPut(cacheKey) { val senderData = cached.getOrPut(cacheKey) {
val senderRoomMember = roomMemberExtractor.extractFrom(eventEntity,realm) val senderRoomMember = roomMemberExtractor.extractFrom(eventEntity, realm)
SenderData(senderRoomMember?.displayName, senderRoomMember?.avatarUrl) SenderData(senderRoomMember?.displayName, senderRoomMember?.avatarUrl)
} }
val relations = relationExtractor.extractFrom(eventEntity, realm)
return TimelineEvent( return TimelineEvent(
eventEntity.asDomain(), eventEntity.asDomain(),
eventEntity.localId, eventEntity.localId,
eventEntity.displayIndex, eventEntity.displayIndex,
senderData.senderName, senderData.senderName,
senderData.senderAvatar, senderData.senderAvatar,
eventEntity.sendState eventEntity.sendState,
relations
) )
} }


fun clear(){ fun clear() {
cached.clear() cached.clear()
} }



View File

@ -24,12 +24,14 @@ 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.deleteOnCascade
import im.vector.matrix.android.internal.database.helper.isUnlinked 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.helper.merge
import im.vector.matrix.android.internal.database.mapper.EventMapper
import im.vector.matrix.android.internal.database.model.ChunkEntity 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.model.RoomEntity
import im.vector.matrix.android.internal.database.query.create 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.find
import im.vector.matrix.android.internal.database.query.findAllIncludingEvents import im.vector.matrix.android.internal.database.query.findAllIncludingEvents
import im.vector.matrix.android.internal.database.query.where 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 im.vector.matrix.android.internal.util.tryTransactionSync
import io.realm.kotlin.createObject import io.realm.kotlin.createObject
import timber.log.Timber import timber.log.Timber
@ -37,7 +39,8 @@ import timber.log.Timber
/** /**
* Insert Chunk in DB, and eventually merge with existing chunk event * Insert Chunk in DB, and eventually merge with existing chunk event
*/ */
internal class TokenChunkEventPersistor(private val monarchy: Monarchy) { internal class TokenChunkEventPersistor(private val monarchy: Monarchy,
private val eventRelationsAggregationUpdater: EventRelationsAggregationUpdater) {


/** /**
* <pre> * <pre>
@ -147,6 +150,9 @@ internal class TokenChunkEventPersistor(private val monarchy: Monarchy) {
} else { } else {
Timber.v("Add ${receivedChunk.events.size} events in chunk(${currentChunk.nextToken} | ${currentChunk.prevToken}") Timber.v("Add ${receivedChunk.events.size} events in chunk(${currentChunk.nextToken} | ${currentChunk.prevToken}")
currentChunk.addAll(roomId, receivedChunk.events, direction, isUnlinked = currentChunk.isUnlinked()) currentChunk.addAll(roomId, receivedChunk.events, direction, isUnlinked = currentChunk.isUnlinked())

//Event
eventRelationsAggregationUpdater.update(realm,roomId,receivedChunk.events.toList())
// Then we merge chunks if needed // Then we merge chunks if needed
if (currentChunk != prevChunk && prevChunk != null) { if (currentChunk != prevChunk && prevChunk != null) {
currentChunk = handleMerge(roomEntity, direction, currentChunk, prevChunk) currentChunk = handleMerge(roomEntity, direction, currentChunk, prevChunk)

View File

@ -31,6 +31,7 @@ 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.find
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where 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.RoomSummaryUpdater
import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection 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.InvitedRoomSync
@ -45,7 +46,8 @@ import timber.log.Timber
internal class RoomSyncHandler(private val monarchy: Monarchy, internal class RoomSyncHandler(private val monarchy: Monarchy,
private val readReceiptHandler: ReadReceiptHandler, private val readReceiptHandler: ReadReceiptHandler,
private val roomSummaryUpdater: RoomSummaryUpdater, private val roomSummaryUpdater: RoomSummaryUpdater,
private val roomTagHandler: RoomTagHandler) { private val roomTagHandler: RoomTagHandler,
private val eventRelationsAggregationUpdater: EventRelationsAggregationUpdater) {


sealed class HandlingStrategy { sealed class HandlingStrategy {
data class JOINED(val data: Map<String, RoomSync>) : HandlingStrategy() data class JOINED(val data: Map<String, RoomSync>) : HandlingStrategy()
@ -120,6 +122,7 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
} }
} }
roomSummaryUpdater.update(realm, roomId, roomSync.summary, roomSync.unreadNotifications) roomSummaryUpdater.update(realm, roomId, roomSync.summary, roomSync.unreadNotifications)
eventRelationsAggregationUpdater.update(realm,roomId,roomSync.timeline?.events)


if (roomSync.ephemeral != null && roomSync.ephemeral.events.isNotEmpty()) { if (roomSync.ephemeral != null && roomSync.ephemeral.events.isNotEmpty()) {
handleEphemeral(realm, roomId, roomSync.ephemeral) handleEphemeral(realm, roomId, roomSync.ephemeral)
@ -174,6 +177,9 @@ internal class RoomSyncHandler(private val monarchy: Monarchy,
lastChunk?.isLastForward = false lastChunk?.isLastForward = false
chunkEntity.isLastForward = true chunkEntity.isLastForward = true
chunkEntity.addAll(roomId, eventList, PaginationDirection.FORWARDS, stateIndexOffset) chunkEntity.addAll(roomId, eventList, PaginationDirection.FORWARDS, stateIndexOffset)

//update eventAnnotationSummary here?

return chunkEntity return chunkEntity
} }



View File

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


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


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {

View File

@ -59,6 +59,7 @@ import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.core.dialogs.DialogListItem import im.vector.riotredesign.core.dialogs.DialogListItem
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotredesign.core.extensions.hideKeyboard
import im.vector.riotredesign.core.extensions.observeEvent import im.vector.riotredesign.core.extensions.observeEvent
import im.vector.riotredesign.core.glide.GlideApp import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.core.platform.ToolbarConfigurable import im.vector.riotredesign.core.platform.ToolbarConfigurable
@ -460,6 +461,7 @@ class RoomDetailFragment :
Timber.e("Missing RoomId, cannot open bottomsheet") Timber.e("Missing RoomId, cannot open bottomsheet")
return false return false
} }
this.view?.hideKeyboard()
MessageActionsBottomSheet MessageActionsBottomSheet
.newInstance(roomId, informationData) .newInstance(roomId, informationData)
.show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS") .show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS")

View File

@ -89,7 +89,7 @@ class MessageActionsBottomSheet : BaseMvRxBottomSheetDialog() {


var quickReactionFragment = cfm.findFragmentByTag("QuickReaction") as? QuickReactionFragment var quickReactionFragment = cfm.findFragmentByTag("QuickReaction") as? QuickReactionFragment
if (quickReactionFragment == null) { if (quickReactionFragment == null) {
quickReactionFragment = QuickReactionFragment.newInstance() quickReactionFragment = QuickReactionFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as ParcelableArgs)
cfm.beginTransaction() cfm.beginTransaction()
.replace(R.id.bottom_sheet_quick_reaction_container, quickReactionFragment, "QuickReaction") .replace(R.id.bottom_sheet_quick_reaction_container, quickReactionFragment, "QuickReaction")
.commit() .commit()

View File

@ -25,6 +25,7 @@ import androidx.transition.TransitionManager
import butterknife.BindView import butterknife.BindView
import butterknife.ButterKnife import butterknife.ButterKnife
import com.airbnb.mvrx.BaseMvRxFragment import com.airbnb.mvrx.BaseMvRxFragment
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import im.vector.riotredesign.R import im.vector.riotredesign.R
@ -62,10 +63,10 @@ class QuickReactionFragment : BaseMvRxFragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)


quickReact1Text.text = viewModel.agreePositive quickReact1Text.text = QuickReactionViewModel.agreePositive
quickReact2Text.text = viewModel.agreeNegative quickReact2Text.text = QuickReactionViewModel.agreeNegative
quickReact3Text.text = viewModel.likePositive quickReact3Text.text = QuickReactionViewModel.likePositive
quickReact4Text.text = viewModel.likeNegative quickReact4Text.text = QuickReactionViewModel.likeNegative


//configure click listeners //configure click listeners
quickReact1Text.setOnClickListener { quickReact1Text.setOnClickListener {
@ -127,8 +128,12 @@ class QuickReactionFragment : BaseMvRxFragment() {
} }


companion object { companion object {
fun newInstance(): QuickReactionFragment { fun newInstance(pa: MessageActionsBottomSheet.ParcelableArgs): QuickReactionFragment {
return QuickReactionFragment() val args = Bundle()
args.putParcelable(MvRx.KEY_ARG, pa)
val fragment = QuickReactionFragment()
fragment.arguments = args
return fragment
} }
} }
} }

View File

@ -18,7 +18,9 @@ package im.vector.riotredesign.features.home.room.detail.timeline.action
import com.airbnb.mvrx.MvRxState import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.session.Session
import im.vector.riotredesign.core.platform.VectorViewModel import im.vector.riotredesign.core.platform.VectorViewModel
import org.koin.android.ext.android.get


/** /**
* Quick reactions state, it's a toggle with 3rd state * Quick reactions state, it's a toggle with 3rd state
@ -37,11 +39,6 @@ data class QuickReactionState(val agreeTrigleState: TriggleState, val likeTriggl
*/ */
class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel<QuickReactionState>(initialState) { class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel<QuickReactionState>(initialState) {


val agreePositive = "👍"
val agreeNegative = "👎"
val likePositive = "😀"
val likeNegative = "😞"



fun toggleAgree(isFirst: Boolean) = withState { fun toggleAgree(isFirst: Boolean) = withState {
if (isFirst) { if (isFirst) {
@ -99,10 +96,37 @@ class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel


companion object : MvRxViewModelFactory<QuickReactionViewModel, QuickReactionState> { companion object : MvRxViewModelFactory<QuickReactionViewModel, QuickReactionState> {


val agreePositive = "👍"
val agreeNegative = "👎"
val likePositive = "🙂"
val likeNegative = "😔"

override fun initialState(viewModelContext: ViewModelContext): QuickReactionState? { override fun initialState(viewModelContext: ViewModelContext): QuickReactionState? {
// Args are accessible from the context. // Args are accessible from the context.
// val foo = vieWModelContext.args<MyArgs>.foo // val foo = vieWModelContext.args<MyArgs>.foo
return QuickReactionState(TriggleState.NONE, TriggleState.NONE) val currentSession = viewModelContext.activity.get<Session>()
val parcel = viewModelContext.args as MessageActionsBottomSheet.ParcelableArgs
val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
?: return null
var agreeTriggle: TriggleState = TriggleState.NONE
var likeTriggle: TriggleState = TriggleState.NONE
event.annotations?.reactionsSummary?.forEach {
//it.addedByMe
if (it.addedByMe) {
if (agreePositive == it.key) {
agreeTriggle = TriggleState.FIRST
} else if (agreeNegative == it.key) {
agreeTriggle = TriggleState.SECOND
}

if (likePositive == it.key) {
likeTriggle = TriggleState.FIRST
} else if (likeNegative == it.key) {
likeTriggle = TriggleState.SECOND
}
}
}
return QuickReactionState(agreeTriggle, likeTriggle)
} }
} }
} }

View File

@ -71,7 +71,8 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
val avatarUrl = event.senderAvatar val avatarUrl = event.senderAvatar
val memberName = event.senderName ?: event.root.sender ?: "" val memberName = event.senderName ?: event.root.sender ?: ""
val formattedMemberName = span(memberName) { val formattedMemberName = span(memberName) {
textColor = colorProvider.getColor(AvatarRenderer.getColorFromUserId(event.root.sender ?: "")) textColor = colorProvider.getColor(AvatarRenderer.getColorFromUserId(event.root.sender
?: ""))
} }
val informationData = MessageInformationData(eventId = eventId, val informationData = MessageInformationData(eventId = eventId,
senderId = event.root.sender ?: "", senderId = event.root.sender ?: "",
@ -79,7 +80,9 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
time = time, time = time,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
memberName = formattedMemberName, memberName = formattedMemberName,
showInformation = showInformation) showInformation = showInformation,
orderedReactionList = event.annotations?.reactionsSummary?.map { Triple(it.key, it.count, it.addedByMe) }
)


//Test for reactions UX //Test for reactions UX
//informationData.orderedReactionList = listOf( Triple("👍",1,false), Triple("👎",2,false)) //informationData.orderedReactionList = listOf( Triple("👍",1,false), Triple("👎",2,false))

View File

@ -19,6 +19,7 @@ package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.os.Build import android.os.Build
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewStub
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.helper.widget.Flow import androidx.constraintlayout.helper.widget.Flow
@ -76,14 +77,19 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
holder.view.setOnLongClickListener(longClickListener) holder.view.setOnLongClickListener(longClickListener)


if (informationData.orderedReactionList.isNullOrEmpty()) { if (informationData.orderedReactionList.isNullOrEmpty()) {
holder.reactionWrapper.isVisible = false holder.reactionWrapper?.isVisible = false
} else { } else {
holder.reactionWrapper.isVisible = true //inflate if needed
if (holder.reactionFlowHelper == null) {
holder.reactionWrapper = holder.view.findViewById<ViewStub>(R.id.messageBottomInfo).inflate() as? ViewGroup
holder.reactionFlowHelper = holder.view.findViewById(R.id.reactionsFlowHelper)
}
holder.reactionWrapper?.isVisible = true
//clear all reaction buttons (but not the Flow helper!) //clear all reaction buttons (but not the Flow helper!)
holder.reactionWrapper.children.forEach { (it as? ReactionButton)?.isGone = true } holder.reactionWrapper?.children?.forEach { (it as? ReactionButton)?.isGone = true }
val idToRefInFlow = ArrayList<Int>() val idToRefInFlow = ArrayList<Int>()
informationData.orderedReactionList?.forEachIndexed { index, reaction -> informationData.orderedReactionList?.forEachIndexed { index, reaction ->
(holder.reactionWrapper.children.elementAt(index) as? ReactionButton)?.let { reactionButton -> (holder.reactionWrapper?.children?.elementAt(index) as? ReactionButton)?.let { reactionButton ->
reactionButton.isVisible = true reactionButton.isVisible = true
idToRefInFlow.add(reactionButton.id) idToRefInFlow.add(reactionButton.id)
reactionButton.reactionString = reaction.first reactionButton.reactionString = reaction.first
@ -93,9 +99,9 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
} }
// Just setting the view as gone will break the FlowHelper (and invisible will take too much space), // Just setting the view as gone will break the FlowHelper (and invisible will take too much space),
// so have to update ref ids // so have to update ref ids
holder.reactionFlowHelper.referencedIds = idToRefInFlow.toIntArray() holder.reactionFlowHelper?.referencedIds = idToRefInFlow.toIntArray()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && !holder.view.isInLayout) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 && !holder.view.isInLayout) {
holder.reactionFlowHelper.requestLayout() holder.reactionFlowHelper?.requestLayout()
} }


} }
@ -112,8 +118,8 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
val memberNameView by bind<TextView>(R.id.messageMemberNameView) val memberNameView by bind<TextView>(R.id.messageMemberNameView)
val timeView by bind<TextView>(R.id.messageTimeView) val timeView by bind<TextView>(R.id.messageTimeView)


val reactionWrapper: ViewGroup by bind(R.id.messageBottomInfo) var reactionWrapper: ViewGroup? = null
val reactionFlowHelper: Flow by bind(R.id.reactionsFlowHelper) var reactionFlowHelper: Flow? = null
} }


} }

View File

@ -4,7 +4,7 @@


<size android:width="40dp" android:height="22dp"/> <size android:width="40dp" android:height="22dp"/>


<solid android:color="@color/light_blue_grey" /> <solid android:color="?vctr_list_header_background_color" />


<stroke android:width="1dp" android:color="@color/accent_color_light" /> <stroke android:width="1dp" android:color="@color/accent_color_light" />



View File

@ -4,9 +4,9 @@


<size android:width="40dp" android:height="22dp"/> <size android:width="40dp" android:height="22dp"/>


<solid android:color="@color/light_blue_grey" /> <solid android:color="?vctr_list_header_background_color" />


<stroke android:width="1dp" android:color="@color/list_divider_color_light" /> <!--<stroke android:width="1dp" android:color="@color/list_divider_color_light" />-->


<corners android:radius="20dp" /> <corners android:radius="20dp" />



View File

@ -82,116 +82,23 @@
tools:ignore="MissingConstraints" /> tools:ignore="MissingConstraints" />





<!-- TODO: For now we show 8 reactions maximum, this will need rework when needed--> <!-- TODO: For now we show 8 reactions maximum, this will need rework when needed-->
<androidx.constraintlayout.widget.ConstraintLayout <ViewStub
android:id="@+id/messageBottomInfo" android:id="@+id/messageBottomInfo"
android:inflatedId="@+id/messageBottomInfo"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_bottom_reactions_stub"
android:visibility="gone" android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/messageStartGuideline" app:layout_constraintStart_toEndOf="@id/messageStartGuideline"
app:layout_constraintVertical_chainStyle="packed" app:layout_constraintVertical_chainStyle="packed"
android:layout_marginBottom="4dp"
tools:visibility="visible"> tools:visibility="visible">


<im.vector.riotredesign.features.reactions.widget.ReactionButton </ViewStub>
android:id="@+id/messageBottomReaction1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="👍"
tools:ignore="MissingConstraints"
tools:reaction_count="3"
tools:visibility="visible" />


<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="👎"
tools:ignore="MissingConstraints"
tools:reaction_count="10"
tools:visibility="visible" />


<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="😀"
tools:ignore="MissingConstraints"
tools:visibility="visible" />


<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="☹️"
tools:ignore="MissingConstraints"
tools:visibility="visible" />


<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="😱"
tools:ignore="MissingConstraints"
tools:visibility="visible" />


<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="❌"
tools:ignore="MissingConstraints"
tools:visibility="visible" />


<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="✔️"
tools:ignore="MissingConstraints"
tools:visibility="visible" />

<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="♥️"
tools:ignore="MissingConstraints"
tools:visibility="visible" />


<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/reactionsFlowHelper"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="2dp"
app:constraint_referenced_ids="messageBottomReaction1,messageBottomReaction2,messageBottomReaction3,messageBottomReaction4,messageBottomReaction5,messageBottomReaction6,messageBottomReaction7,messageBottomReaction8"
app:flow_horizontalBias="0"
app:flow_horizontalGap="8dp"
app:flow_horizontalStyle="packed"
app:flow_verticalBias="0"
app:flow_verticalGap="4dp"
app:flow_wrapMode="chain"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>




</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,102 @@
<?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/messageBottomInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="👍"
tools:ignore="MissingConstraints"
tools:reaction_count="3"
tools:visibility="visible" />

<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="👎"
tools:ignore="MissingConstraints"
tools:reaction_count="10"
tools:visibility="visible" />

<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="😀"
tools:ignore="MissingConstraints"
tools:visibility="visible" />

<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction4"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="☹️"
tools:ignore="MissingConstraints"
tools:visibility="visible" />

<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction5"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="😱"
tools:ignore="MissingConstraints"
tools:visibility="visible" />


<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction6"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="❌"
tools:ignore="MissingConstraints"
tools:visibility="visible" />


<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction7"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="✔️"
tools:ignore="MissingConstraints"
tools:visibility="visible" />

<im.vector.riotredesign.features.reactions.widget.ReactionButton
android:id="@+id/messageBottomReaction8"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
tools:emoji="♥️"
tools:ignore="MissingConstraints"
tools:visibility="visible" />


<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/reactionsFlowHelper"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="2dp"
app:constraint_referenced_ids="messageBottomReaction1,messageBottomReaction2,messageBottomReaction3,messageBottomReaction4,messageBottomReaction5,messageBottomReaction6,messageBottomReaction7,messageBottomReaction8"
app:flow_horizontalBias="0"
app:flow_horizontalGap="8dp"
app:flow_horizontalStyle="packed"
app:flow_verticalBias="0"
app:flow_verticalGap="4dp"
app:flow_wrapMode="chain"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -252,7 +252,7 @@
<item name="android:layout_height">wrap_content</item> <item name="android:layout_height">wrap_content</item>
<item name="android:layout_marginStart">8dp</item> <item name="android:layout_marginStart">8dp</item>
<item name="android:layout_marginLeft">8dp</item> <item name="android:layout_marginLeft">8dp</item>
<item name="android:layout_marginBottom">8dp</item> <item name="android:layout_marginBottom">4dp</item>
<item name="android:layout_marginTop">4dp</item> <item name="android:layout_marginTop">4dp</item>
<item name="layout_constraintBottom_toBottomOf">parent</item> <item name="layout_constraintBottom_toBottomOf">parent</item>
<item name="layout_constraintEnd_toEndOf">parent</item> <item name="layout_constraintEnd_toEndOf">parent</item>