diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/EventAnnotationsSummary.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/EventAnnotationsSummary.kt index 28edfcfe04..89fca26aef 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/EventAnnotationsSummary.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/EventAnnotationsSummary.kt @@ -19,5 +19,6 @@ data class EventAnnotationsSummary( var eventId: String, var reactionsSummary: List, var editSummary: EditAggregatedSummary?, + var pollResponseSummary: PollResponseAggregatedSummary?, var referencesAggregatedSummary: ReferencesAggregatedSummary? = null ) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/PollResponseAggregatedSummary.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/PollResponseAggregatedSummary.kt new file mode 100644 index 0000000000..3256e5d7e1 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/PollResponseAggregatedSummary.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2020 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 + +data class PollResponseAggregatedSummary( + + var aggregatedContent: PollSummaryContent? = null, + + // If set the poll is closed (Clients SHOULD NOT consider responses after the close event) + var closedTime: Long? = null, + // Clients SHOULD validate that the option in the relationship is a valid option, and ignore the response if invalid + var nbOptions: Int = 0, + // The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk) + val sourceEvents: List, + val localEchos: List +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/PollSummaryContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/PollSummaryContent.kt new file mode 100644 index 0000000000..1b9ab4fc5b --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/PollSummaryContent.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2020 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 + +import com.squareup.moshi.JsonClass + +/** + * Contains an aggregated summary info of the poll response. + * Put pre-computed info that you want to access quickly without having + * to go through all references events + */ +@JsonClass(generateAdapter = true) +data class PollSummaryContent( + //Index of my vote + var myVote: Int? = null, + // Array of VoteInfo, list is constructed so that there is only one vote by user + // And that optionIndex is valid + var votes: List? = null +) { + + fun voteCount(): Int { + return votes?.size ?: 0 + } + + fun voteCountForOption(optionIndex: Int) : Int { + return votes?.filter { it.optionIndex == optionIndex }?.count() ?: 0 + } +} + +@JsonClass(generateAdapter = true) +data class VoteInfo( + val userId: String, + val optionIndex: Int, + val voteTimestamp: Long +) diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/EventAnnotationsSummaryMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/EventAnnotationsSummaryMapper.kt index ccdb8fb91f..cccdea68b1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/EventAnnotationsSummaryMapper.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/EventAnnotationsSummaryMapper.kt @@ -16,12 +16,16 @@ package im.vector.matrix.android.internal.database.mapper +import im.vector.matrix.android.api.session.events.model.toContent +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.EventAnnotationsSummary +import im.vector.matrix.android.api.session.room.model.PollResponseAggregatedSummary import im.vector.matrix.android.api.session.room.model.ReactionAggregatedSummary import im.vector.matrix.android.api.session.room.model.ReferencesAggregatedSummary import im.vector.matrix.android.internal.database.model.EditAggregatedSummaryEntity import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity +import im.vector.matrix.android.internal.database.model.PollResponseAggregatedSummaryEntity import im.vector.matrix.android.internal.database.model.ReactionAggregatedSummaryEntity import im.vector.matrix.android.internal.database.model.ReferencesAggregatedSummaryEntity import io.realm.RealmList @@ -55,7 +59,11 @@ internal object EventAnnotationsSummaryMapper { it.sourceEvents.toList(), it.sourceLocalEcho.toList() ) + }, + pollResponseSummary = annotationsSummary.pollResponseSummary?.let { + PollResponseAggregatedSummaryEntityMapper.map(it) } + ) } @@ -93,6 +101,9 @@ internal object EventAnnotationsSummaryMapper { RealmList().apply { addAll(it.localEchos) } ) } + eventAnnotationsSummaryEntity.pollResponseSummary = annotationsSummary.pollResponseSummary?.let { + PollResponseAggregatedSummaryEntityMapper.map(it) + } return eventAnnotationsSummaryEntity } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/PollResponseAggregatedSummaryEntityMapper.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/PollResponseAggregatedSummaryEntityMapper.kt new file mode 100644 index 0000000000..fc3bd7f1fc --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/mapper/PollResponseAggregatedSummaryEntityMapper.kt @@ -0,0 +1,50 @@ +package im.vector.matrix.android.internal.database.mapper +/* + * 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. + */ + +import im.vector.matrix.android.api.session.events.model.toContent +import im.vector.matrix.android.api.session.events.model.toModel +import im.vector.matrix.android.api.session.room.model.PollResponseAggregatedSummary +import im.vector.matrix.android.internal.database.model.PollResponseAggregatedSummaryEntity +import io.realm.RealmList + +internal object PollResponseAggregatedSummaryEntityMapper { + + fun map(entity: PollResponseAggregatedSummaryEntity): PollResponseAggregatedSummary { + return PollResponseAggregatedSummary( + aggregatedContent = ContentMapper.map(entity.aggregatedContent).toModel(), + closedTime = entity.closedTime, + localEchos = entity.sourceLocalEchoEvents.toList(), + sourceEvents = entity.sourceEvents.toList(), + nbOptions = entity.nbOptions + ) + } + + + fun map(model: PollResponseAggregatedSummary): PollResponseAggregatedSummaryEntity { + return PollResponseAggregatedSummaryEntity( + aggregatedContent = ContentMapper.map(model.aggregatedContent.toContent()), + nbOptions = model.nbOptions, + closedTime = model.closedTime, + sourceEvents = RealmList().apply { addAll(model.sourceEvents) }, + sourceLocalEchoEvents = RealmList().apply { addAll(model.localEchos) } + ) + } +} + +internal fun PollResponseAggregatedSummaryEntity.asDomain(): PollResponseAggregatedSummary { + return PollResponseAggregatedSummaryEntityMapper.map(this) +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventAnnotationsSummaryEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventAnnotationsSummaryEntity.kt index 1a4f72f0b1..8913f76b99 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventAnnotationsSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/EventAnnotationsSummaryEntity.kt @@ -25,7 +25,8 @@ internal open class EventAnnotationsSummaryEntity( var roomId: String? = null, var reactionsSummary: RealmList = RealmList(), var editSummary: EditAggregatedSummaryEntity? = null, - var referencesSummaryEntity: ReferencesAggregatedSummaryEntity? = null + var referencesSummaryEntity: ReferencesAggregatedSummaryEntity? = null, + var pollResponseSummary: PollResponseAggregatedSummaryEntity? = null ) : RealmObject() { companion object diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/PollResponseAggregatedSummaryEntity.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/PollResponseAggregatedSummaryEntity.kt new file mode 100644 index 0000000000..b8eeeff96e --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/PollResponseAggregatedSummaryEntity.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2020 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 + +/** + * Keep the latest state of edition of a message + */ +internal open class PollResponseAggregatedSummaryEntity( + // For now we persist this a JSON for greater flexibility + // #see PollSummaryContent + var aggregatedContent: String? = null, + + // If set the poll is closed (Clients SHOULD NOT consider responses after the close event) + var closedTime: Long? = null, + // Clients SHOULD validate that the option in the relationship is a valid option, and ignore the response if invalid + var nbOptions: Int = 0, + + // The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk) + var sourceEvents: RealmList = RealmList(), + var sourceLocalEchoEvents: RealmList = RealmList() +) : RealmObject() { + + companion object +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt index 298e887f0f..74768f8797 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/model/SessionRealmModule.kt @@ -40,6 +40,7 @@ import io.realm.annotations.RealmModule EventAnnotationsSummaryEntity::class, ReactionAggregatedSummaryEntity::class, EditAggregatedSummaryEntity::class, + PollResponseAggregatedSummaryEntity::class, ReferencesAggregatedSummaryEntity::class, PushRulesEntity::class, PushRuleEntity::class, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/FilterContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/FilterContent.kt index be7075ddd0..6e89a28b7d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/FilterContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/database/query/FilterContent.kt @@ -19,4 +19,5 @@ package im.vector.matrix.android.internal.database.query internal object FilterContent { internal const val EDIT_TYPE = """{*"m.relates_to"*"rel_type":*"m.replace"*}""" + internal const val RESPONSE_TYPE = """{*"m.relates_to"*"rel_type":*"m.response"*}""" } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MoshiProvider.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MoshiProvider.kt index 03b2c9d41d..fdf7c1b597 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MoshiProvider.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/MoshiProvider.kt @@ -48,6 +48,7 @@ object MoshiProvider { .registerSubtype(MessageFileContent::class.java, MessageType.MSGTYPE_FILE) .registerSubtype(MessageOptionsContent::class.java, MessageType.MSGTYPE_OPTIONS) .registerSubtype(MessageVerificationRequestContent::class.java, MessageType.MSGTYPE_VERIFICATION_REQUEST) + .registerSubtype(MessagePollResponseContent::class.java, MessageType.MSGTYPE_RESPONSE) ) .add(SerializeNulls.JSON_ADAPTER_FACTORY) .build() diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt index def7f2c2f7..4235a87f9e 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/EventRelationsAggregationTask.kt @@ -26,8 +26,12 @@ 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.events.model.toModel import im.vector.matrix.android.api.session.room.model.ReferencesAggregatedContent +import im.vector.matrix.android.api.session.events.model.* +import im.vector.matrix.android.api.session.room.model.PollSummaryContent +import im.vector.matrix.android.api.session.room.model.VoteInfo import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageRelationContent +import im.vector.matrix.android.api.session.room.model.message.MessagePollResponseContent import im.vector.matrix.android.api.session.room.model.relation.ReactionContent import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent @@ -36,6 +40,7 @@ import im.vector.matrix.android.internal.database.mapper.EventMapper import im.vector.matrix.android.internal.database.model.EditAggregatedSummaryEntity 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.model.PollResponseAggregatedSummaryEntity import im.vector.matrix.android.internal.database.model.ReactionAggregatedSummaryEntity import im.vector.matrix.android.internal.database.model.ReactionAggregatedSummaryEntityFields import im.vector.matrix.android.internal.database.model.ReferencesAggregatedSummaryEntity @@ -123,6 +128,9 @@ internal class DefaultEventRelationsAggregationTask @Inject constructor( Timber.v("###REPLACE in room $roomId for event ${event.eventId}") // A replace! handleReplace(realm, event, content, roomId, isLocalEcho) + } else if (content?.relatesTo?.type == RelationType.RESPONSE) { + Timber.v("###RESPONSE in room $roomId for event ${event.eventId}") + handleResponse(realm,userId,event, content, roomId, isLocalEcho) } } @@ -144,13 +152,20 @@ internal class DefaultEventRelationsAggregationTask @Inject constructor( EventType.ENCRYPTED -> { // Relation type is in clear val encryptedEventContent = event.content.toModel() - if (encryptedEventContent?.relatesTo?.type == RelationType.REPLACE) { + if (encryptedEventContent?.relatesTo?.type == RelationType.REPLACE + || encryptedEventContent?.relatesTo?.type == RelationType.RESPONSE + ) { // we need to decrypt if needed decryptIfNeeded(event) event.getClearContent().toModel()?.let { - Timber.v("###REPLACE in room $roomId for event ${event.eventId}") - // A replace! - handleReplace(realm, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) + if (encryptedEventContent.relatesTo.type == RelationType.REPLACE) { + Timber.v("###REPLACE in room $roomId for event ${event.eventId}") + // A replace! + handleReplace(realm, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) + } else if (encryptedEventContent.relatesTo.type == RelationType.RESPONSE) { + Timber.v("###RESPONSE in room $roomId for event ${event.eventId}") + handleResponse(realm, userId, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) + } } } else if (encryptedEventContent?.relatesTo?.type == RelationType.REFERENCE) { decryptIfNeeded(event) @@ -276,6 +291,90 @@ internal class DefaultEventRelationsAggregationTask @Inject constructor( } } + private fun handleResponse(realm: Realm, userId: String, event: Event, content: MessageContent, roomId: String, isLocalEcho: Boolean, relatedEventId: String? = null) { + val eventId = event.eventId ?: return + val senderId = event.senderId ?: return + val targetEventId = relatedEventId ?: content.relatesTo?.eventId ?: return + val eventTimestamp = event.originServerTs ?: return + + // ok, this is a poll response + var existing = EventAnnotationsSummaryEntity.where(realm, targetEventId).findFirst() + if (existing == null) { + Timber.v("## POLL creating new relation summary for $targetEventId") + existing = EventAnnotationsSummaryEntity.create(realm, roomId, targetEventId) + } + + // we have it + val existingPollSummary = existing.pollResponseSummary + ?: realm.createObject(PollResponseAggregatedSummaryEntity::class.java).also { + existing.pollResponseSummary = it + } + + val closedTime = existingPollSummary?.closedTime + if (closedTime != null && eventTimestamp > closedTime) { + Timber.v("## POLL is closed ignore event poll:$targetEventId, event :${event.eventId}") + return + } + + val sumModel = ContentMapper.map(existingPollSummary?.aggregatedContent).toModel() ?: PollSummaryContent() + + if (existingPollSummary!!.sourceEvents.contains(eventId)) { + // ignore this event, we already know it (??) + Timber.v("## POLL ignoring event for summary, it's known eventId:$eventId") + return + } + val txId = event.unsignedData?.transactionId + // is it a remote echo? + if (!isLocalEcho && existingPollSummary.sourceLocalEchoEvents.contains(txId)) { + // ok it has already been managed + Timber.v("## POLL Receiving remote echo of response eventId:$eventId") + existingPollSummary.sourceLocalEchoEvents.remove(txId) + existingPollSummary.sourceEvents.add(event.eventId) + return + } + + val responseContent = event.content.toModel() ?: return Unit.also { + Timber.d("## POLL Receiving malformed response eventId:$eventId content: ${event.content}") + } + + val optionIndex = responseContent.relatesTo?.option ?: return Unit.also { + Timber.d("## POLL Ignoring malformed response no option eventId:$eventId content: ${event.content}") + } + + val votes = sumModel.votes?.toMutableList() ?: ArrayList() + val existingVoteIndex = votes.indexOfFirst { it.userId == senderId } + if (existingVoteIndex != -1) { + //Is the vote newer? + val existingVote = votes[existingVoteIndex] + if (existingVote.voteTimestamp < eventTimestamp) { + //Take the new one + votes[existingVoteIndex] = VoteInfo(senderId,optionIndex, eventTimestamp) + if (userId == senderId) { + sumModel.myVote = optionIndex + } + Timber.v("## POLL adding vote $optionIndex for user $senderId in poll :$relatedEventId ") + } else { + Timber.v("## POLL Ignoring vote (older than known one) eventId:$eventId ") + } + } else { + votes.add(VoteInfo(senderId,optionIndex, eventTimestamp)) + if (userId == senderId) { + sumModel.myVote = optionIndex + } + Timber.v("## POLL adding vote $optionIndex for user $senderId in poll :$relatedEventId ") + } + sumModel.votes = votes + if (isLocalEcho) { + existingPollSummary.sourceLocalEchoEvents.add(eventId) + } else { + existingPollSummary.sourceEvents.add(eventId) + } + + existingPollSummary.aggregatedContent = ContentMapper.map(sumModel.toContent()) + } + + + private fun handleInitialAggregatedRelations(event: Event, roomId: String, aggregation: AggregatedAnnotation, realm: Realm) { if (SHOULD_HANDLE_SERVER_AGREGGATION) { aggregation.chunk?.forEach { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt index d24cb8a19b..c5cc85f8e0 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -34,6 +34,7 @@ import im.vector.matrix.android.api.session.room.model.message.MessageFileConten import im.vector.matrix.android.api.session.room.model.message.MessageImageInfoContent import im.vector.matrix.android.api.session.room.model.message.MessageNoticeContent import im.vector.matrix.android.api.session.room.model.message.MessageOptionsContent +import im.vector.matrix.android.api.session.room.model.message.MessagePollResponseContent import im.vector.matrix.android.api.session.room.model.message.MessageTextContent import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.MessageVerificationRequestContent @@ -65,6 +66,7 @@ import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem_ import im.vector.riotx.features.home.room.detail.timeline.item.MessageImageVideoItem import im.vector.riotx.features.home.room.detail.timeline.item.MessageImageVideoItem_ import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData +import im.vector.riotx.features.home.room.detail.timeline.item.MessagePollItem_ import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem import im.vector.riotx.features.home.room.detail.timeline.item.MessageTextItem_ import im.vector.riotx.features.home.room.detail.timeline.item.RedactedMessageItem @@ -121,7 +123,7 @@ class MessageItemFactory @Inject constructor( if (messageContent.relatesTo?.type == RelationType.REPLACE || event.isEncrypted() && event.root.content.toModel()?.relatesTo?.type == RelationType.REPLACE ) { - // This is an edit event, we should it when debugging as a notice event + // This is an edit event, we should display it when debugging as a notice event return noticeItemFactory.create(event, highlight, callback) } val attributes = messageItemAttributesFactory.create(messageContent, informationData, callback) @@ -138,7 +140,8 @@ class MessageItemFactory @Inject constructor( is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageOptionsContent -> buildPollMessageItem(messageContent, informationData, highlight, callback, attributes) - else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback) + is MessagePollResponseContent -> noticeItemFactory.create(event, highlight, callback) + else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index cbbfd7c320..9ed506d355 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -30,6 +30,7 @@ import im.vector.riotx.core.extensions.localDateTime import im.vector.riotx.core.resources.ColorProvider import im.vector.riotx.core.utils.getColorFromUserId import im.vector.riotx.features.home.room.detail.timeline.item.MessageInformationData +import im.vector.riotx.features.home.room.detail.timeline.item.PollResponseData import im.vector.riotx.features.home.room.detail.timeline.item.ReactionInfoData import im.vector.riotx.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.riotx.features.home.room.detail.timeline.item.ReferencesInfoData @@ -82,6 +83,15 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses ?.map { ReactionInfoData(it.key, it.count, it.addedByMe, it.localEchoEvents.isEmpty()) }, + pollResponseAggregatedSummary = event.annotations?.pollResponseSummary?.let { + PollResponseData( + myVote = it.aggregatedContent?.myVote, + isClosed = it.closedTime ?: Long.MAX_VALUE > System.currentTimeMillis(), + votes = it.aggregatedContent?.votes + ?.groupBy ({ it.optionIndex }, { it.userId }) + ?.mapValues { it.value.size } + ) + }, hasBeenEdited = event.hasBeenEdited(), hasPendingEdits = event.annotations?.editSummary?.localEchos?.any() ?: false, readReceipts = event.readReceipts diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt index fe5d0d03ca..8d4ae81201 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageInformationData.kt @@ -34,6 +34,8 @@ data class MessageInformationData( val showInformation: Boolean = true, /*List of reactions (emoji,count,isSelected)*/ val orderedReactionList: List? = null, + val pollResponseAggregatedSummary: PollResponseData? = null, + val hasBeenEdited: Boolean = false, val hasPendingEdits: Boolean = false, val readReceipts: List = emptyList(), @@ -66,4 +68,11 @@ data class ReadReceiptData( val timestamp: Long ) : Parcelable +@Parcelize +data class PollResponseData( + val myVote: Int?, + val votes: Map?, + val isClosed: Boolean = false +) : Parcelable + fun ReadReceiptData.toMatrixItem() = MatrixItem.UserItem(userId, displayName, avatarUrl) diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessagePollItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessagePollItem.kt index fcabb44ba6..ddae2ed1f5 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessagePollItem.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessagePollItem.kt @@ -28,6 +28,7 @@ import im.vector.riotx.core.extensions.setTextOrHide import im.vector.riotx.core.utils.DebouncedClickListener import im.vector.riotx.features.home.room.detail.RoomDetailAction import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController +import kotlin.math.roundToInt @EpoxyModelClass(layout = R.layout.item_timeline_event_base) abstract class MessagePollItem : AbsMessageItem() { @@ -55,32 +56,51 @@ abstract class MessagePollItem : AbsMessageItem() { holder.labelText.setTextOrHide(optionsContent?.label) val buttons = listOf(holder.button1, holder.button2, holder.button3, holder.button4, holder.button5) - - buttons.forEach { it.isVisible = false } - - optionsContent?.options?.forEachIndexed { index, item -> - if (index < buttons.size) { - buttons[index].let { - it.text = item.label - it.isVisible = true - } - } - } - val resultLines = listOf(holder.result1, holder.result2, holder.result3, holder.result4, holder.result5) + buttons.forEach { it.isVisible = false } resultLines.forEach { it.isVisible = false } - optionsContent?.options?.forEachIndexed { index, item -> - if (index < resultLines.size) { - resultLines[index].let { - it.label = item.label - it.optionSelected = index == 0 - it.percent = "20%" - it.isVisible = true + + val myVote = informationData?.pollResponseAggregatedSummary?.myVote + val iHaveVoted = myVote != null + val votes = informationData?.pollResponseAggregatedSummary?.votes + val totalVotes = votes?.values + ?.fold(0) { acc, count -> acc + count } ?: 0 + val percentMode = totalVotes > 100 + + if (!iHaveVoted) { + // Show buttons if i have not voted + optionsContent?.options?.forEachIndexed { index, item -> + if (index < buttons.size) { + buttons[index].let { + it.text = item.label + it.isVisible = true + } + } + } + } else { + val maxCount = votes?.maxBy { it.value }?.value ?: 0 + optionsContent?.options?.forEachIndexed { index, item -> + if (index < resultLines.size) { + val optionCount = votes?.get(index) ?: 0 + val count = if (percentMode) { + if (totalVotes > 0) + (optionCount / totalVotes.toFloat() * 100).roundToInt().let { "$it%" } + else "" + } else { + optionCount.toString() + } + resultLines[index].let { + it.label = item.label + it.isWinner = optionCount == maxCount + it.optionSelected = index == myVote + it.percent = count + it.isVisible = true + } } } } - holder.infoText.text = holder.view.context.resources.getQuantityString(R.plurals.poll_info, 0, 0) + holder.infoText.text = holder.view.context.resources.getQuantityString(R.plurals.poll_info, totalVotes, totalVotes) } override fun unbind(holder: Holder) { @@ -93,7 +113,7 @@ abstract class MessagePollItem : AbsMessageItem() { class Holder : AbsMessageItem.Holder(STUB_ID) { var pollId: String? = null - var optionValues : List? = null + var optionValues: List? = null var callback: TimelineEventController.Callback? = null val button1 by bind