diff --git a/changelog.d/4653.feature b/changelog.d/4653.feature new file mode 100644 index 0000000000..d53d322c0c --- /dev/null +++ b/changelog.d/4653.feature @@ -0,0 +1 @@ +Poll Feature - Render in timeline \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt index a39ca5b4f4..0c77b574e7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt @@ -104,6 +104,8 @@ object EventType { // Poll const val POLL_START = "org.matrix.msc3381.poll.start" + const val POLL_RESPONSE = "org.matrix.msc3381.poll.response" + const val POLL_END = "org.matrix.msc3381.poll.end" // Unwedging internal const val DUMMY = "m.dummy" diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PollSummaryContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PollSummaryContent.kt index 844ef6c1c8..f1e4354314 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PollSummaryContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PollSummaryContent.kt @@ -24,25 +24,24 @@ import com.squareup.moshi.JsonClass */ @JsonClass(generateAdapter = true) data class PollSummaryContent( - // Index of my vote - var myVote: Int? = null, + var myVote: String? = 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 -) { + var votes: List? = null, + var votesSummary: Map? = null, + var totalVotes: Int = 0, + var winnerVoteCount: Int = 0 +) - 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 VoteSummary( + val total: Int = 0, + val percentage: Double = 0.0 +) @JsonClass(generateAdapter = true) data class VoteInfo( val userId: String, - val optionIndex: Int, + val option: String, val voteTimestamp: Long ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageEndPollContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageEndPollContent.kt new file mode 100644 index 0000000000..491b71477e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageEndPollContent.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2021 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.api.session.room.model.message + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent + +/** + * Class representing the org.matrix.msc3381.poll.end event content + */ +@JsonClass(generateAdapter = true) +data class MessageEndPollContent( + @Json(name = "m.relates_to") val relatesTo: RelationDefaultContent? = null +) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageOptionsContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageOptionsContent.kt deleted file mode 100644 index 7a1a99bd5f..0000000000 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageOptionsContent.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2020 The Matrix.org Foundation C.I.C. - * - * 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 org.matrix.android.sdk.api.session.room.model.message - -import com.squareup.moshi.Json -import com.squareup.moshi.JsonClass -import org.matrix.android.sdk.api.session.events.model.Content -import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent - -// Possible values for optionType -const val OPTION_TYPE_POLL = "org.matrix.poll" -const val OPTION_TYPE_BUTTONS = "org.matrix.buttons" - -/** - * Polls and bot buttons are m.room.message events with a msgtype of m.options, - * Ref: https://github.com/matrix-org/matrix-doc/pull/2192 - */ -@JsonClass(generateAdapter = true) -data class MessageOptionsContent( - @Json(name = MessageContent.MSG_TYPE_JSON_KEY) override val msgType: String = MessageType.MSGTYPE_OPTIONS, - @Json(name = "type") val optionType: String? = null, - @Json(name = "body") override val body: String, - @Json(name = "label") val label: String?, - @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, - @Json(name = "options") val options: List? = null, - @Json(name = "m.new_content") override val newContent: Content? = null -) : MessageContent diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollContent.kt index ef2fd1867a..a4e1317290 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollContent.kt @@ -18,8 +18,18 @@ package org.matrix.android.sdk.api.session.room.model.message import com.squareup.moshi.Json import com.squareup.moshi.JsonClass +import org.matrix.android.sdk.api.session.events.model.Content +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent @JsonClass(generateAdapter = true) data class MessagePollContent( - @Json(name = "org.matrix.msc3381.poll.start") val pollCreationInfo: PollCreationInfo? = null -) + /** + * Local message type, not from server + */ + @Transient + override val msgType: String = MessageType.MSGTYPE_POLL_START, + @Json(name = "body") override val body: String = "", + @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, + @Json(name = "m.new_content") override val newContent: Content? = null, + @Json(name = "org.matrix.msc3381.poll.start") val pollCreationInfo: PollCreationInfo? = null +) : MessageContent diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollResponseContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollResponseContent.kt index 9edfe118b0..f3b4e3dc23 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollResponseContent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessagePollResponseContent.kt @@ -21,13 +21,15 @@ import com.squareup.moshi.JsonClass import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent -/** - * Ref: https://github.com/matrix-org/matrix-doc/pull/2192 - */ @JsonClass(generateAdapter = true) data class MessagePollResponseContent( - @Json(name = MessageContent.MSG_TYPE_JSON_KEY) override val msgType: String = MessageType.MSGTYPE_RESPONSE, - @Json(name = "body") override val body: String, + /** + * Local message type, not from server + */ + @Transient + override val msgType: String = MessageType.MSGTYPE_POLL_RESPONSE, + @Json(name = "body") override val body: String = "", @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, - @Json(name = "m.new_content") override val newContent: Content? = null + @Json(name = "m.new_content") override val newContent: Content? = null, + @Json(name = "org.matrix.msc3381.poll.response") val response: PollResponse? = null ) : MessageContent diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt index 1e8959afc3..2a6138ae60 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageType.kt @@ -25,15 +25,18 @@ object MessageType { const val MSGTYPE_VIDEO = "m.video" const val MSGTYPE_LOCATION = "m.location" const val MSGTYPE_FILE = "m.file" - const val MSGTYPE_OPTIONS = "org.matrix.options" - const val MSGTYPE_RESPONSE = "org.matrix.response" - const val MSGTYPE_POLL_CLOSED = "org.matrix.poll_closed" + const val MSGTYPE_VERIFICATION_REQUEST = "m.key.verification.request" // Add, in local, a fake message type in order to StickerMessage can inherit Message class // Because sticker isn't a message type but a event type without msgtype field const val MSGTYPE_STICKER_LOCAL = "org.matrix.android.sdk.sticker" + // Fake message types for poll events to be able to inherit them from MessageContent + // Because poll events are not message events and they don't hanve msgtype field + const val MSGTYPE_POLL_START = "org.matrix.android.sdk.poll.start" + const val MSGTYPE_POLL_RESPONSE = "org.matrix.android.sdk.poll.response" + const val MSGTYPE_CONFETTI = "nic.custom.confetti" const val MSGTYPE_SNOWFALL = "io.element.effect.snowfall" } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/OptionItem.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollResponse.kt similarity index 75% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/OptionItem.kt rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollResponse.kt index 625043df87..ddeec5cd5b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/OptionItem.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/PollResponse.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2021 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,11 +19,7 @@ package org.matrix.android.sdk.api.session.room.model.message import com.squareup.moshi.Json import com.squareup.moshi.JsonClass -/** - * Ref: https://github.com/matrix-org/matrix-doc/pull/2192 - */ @JsonClass(generateAdapter = true) -data class OptionItem( - @Json(name = "label") val label: String?, - @Json(name = "value") val value: String? +data class PollResponse( + @Json(name = "answers") val answers: List? = null ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt index a2b38b6606..5b387c3413 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt @@ -91,11 +91,17 @@ interface SendService { /** * Method to send a poll response. * @param pollEventId the poll currently replied to - * @param optionIndex The reply index - * @param optionValue The option value (for compatibility) + * @param answerId The id of the answer * @return a [Cancelable] */ - fun sendOptionsReply(pollEventId: String, optionIndex: Int, optionValue: String): Cancelable + fun voteToPoll(pollEventId: String, answerId: String): Cancelable + + /** + * End a poll in the room. + * @param pollEventId event id of the poll + * @return a [Cancelable] + */ + fun endPoll(pollEventId: String): Cancelable /** * Redact (delete) the given event. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt index ef6300eae2..3bba2deae5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt @@ -32,6 +32,7 @@ object RoomSummaryConstants { EventType.CALL_ANSWER, EventType.ENCRYPTED, EventType.STICKER, - EventType.REACTION + EventType.REACTION, + EventType.POLL_START ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index 86cb10bfe8..932439c81c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt @@ -27,6 +27,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary import org.matrix.android.sdk.api.session.room.model.ReadReceipt import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent @@ -126,10 +127,10 @@ fun TimelineEvent.getEditedEventId(): String? { * Get last MessageContent, after a possible edition */ fun TimelineEvent.getLastMessageContent(): MessageContent? { - return if (root.getClearType() == EventType.STICKER) { - root.getClearContent().toModel() - } else { - (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() + return when (root.getClearType()) { + EventType.STICKER -> root.getClearContent().toModel() + EventType.POLL_START -> root.getClearContent().toModel() + else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MoshiProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MoshiProvider.kt index 074f8dc43e..9e50e9efe8 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MoshiProvider.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/di/MoshiProvider.kt @@ -25,7 +25,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageFileContent import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent import org.matrix.android.sdk.api.session.room.model.message.MessageLocationContent import org.matrix.android.sdk.api.session.room.model.message.MessageNoticeContent -import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType @@ -57,8 +56,7 @@ object MoshiProvider { .registerSubtype(MessageLocationContent::class.java, MessageType.MSGTYPE_LOCATION) .registerSubtype(MessageFileContent::class.java, MessageType.MSGTYPE_FILE) .registerSubtype(MessageVerificationRequestContent::class.java, MessageType.MSGTYPE_VERIFICATION_REQUEST) - .registerSubtype(MessageOptionsContent::class.java, MessageType.MSGTYPE_OPTIONS) - .registerSubtype(MessagePollResponseContent::class.java, MessageType.MSGTYPE_RESPONSE) + .registerSubtype(MessagePollResponseContent::class.java, MessageType.MSGTYPE_POLL_RESPONSE) ) .add(SerializeNulls.JSON_ADAPTER_FACTORY) .build() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt index 0ac21b555e..da15e158e5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/notification/ProcessEventForPushTask.kt @@ -56,6 +56,7 @@ internal class DefaultProcessEventForPushTask @Inject constructor( val allEvents = (newJoinEvents + inviteEvents).filter { event -> when (event.type) { + EventType.POLL_START, EventType.MESSAGE, EventType.REDACTION, EventType.ENCRYPTED, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt index 5a1eb190a8..62b6d626f5 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt @@ -17,20 +17,27 @@ package org.matrix.android.sdk.internal.session.room import io.realm.Realm import org.matrix.android.sdk.api.crypto.VerificationState +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.events.model.AggregatedAnnotation import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.LocalEcho import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.PollSummaryContent +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedContent import org.matrix.android.sdk.api.session.room.model.VoteInfo +import org.matrix.android.sdk.api.session.room.model.VoteSummary import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageEndPollContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent import org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent +import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent import org.matrix.android.sdk.internal.crypto.verification.toState import org.matrix.android.sdk.internal.database.mapper.ContentMapper @@ -50,11 +57,13 @@ import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.where import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.session.EventInsertLiveProcessor +import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource import timber.log.Timber import javax.inject.Inject internal class EventRelationsAggregationProcessor @Inject constructor( - @UserId private val userId: String + @UserId private val userId: String, + private val stateEventDataSource: StateEventDataSource ) : EventInsertLiveProcessor { private val allowedTypes = listOf( @@ -69,7 +78,9 @@ internal class EventRelationsAggregationProcessor @Inject constructor( // TODO Add ? // EventType.KEY_VERIFICATION_READY, EventType.KEY_VERIFICATION_KEY, - EventType.ENCRYPTED + EventType.ENCRYPTED, + EventType.POLL_RESPONSE, + EventType.POLL_END ) override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean { @@ -107,9 +118,6 @@ internal class EventRelationsAggregationProcessor @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, event, content, roomId, isLocalEcho) } } @@ -139,9 +147,11 @@ internal class EventRelationsAggregationProcessor @Inject constructor( 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, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) + } else if (event.getClearType() == EventType.POLL_RESPONSE) { + event.getClearContent().toModel(catchError = true)?.let { pollResponseContent -> + Timber.v("###RESPONSE in room $roomId for event ${event.eventId}") + handleResponse(realm, event, pollResponseContent, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) + } } } } else if (encryptedEventContent?.relatesTo?.type == RelationType.REFERENCE) { @@ -158,6 +168,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor( handleVerification(realm, event, roomId, isLocalEcho, it) } } + EventType.POLL_RESPONSE -> { + event.getClearContent().toModel(catchError = true)?.let { + handleResponse(realm, event, it, roomId, isLocalEcho, event.getRelationContent()?.eventId) + } + } + EventType.POLL_END -> { + event.content.toModel(catchError = true)?.let { + handleEndPoll(realm, event, it, roomId, isLocalEcho) + } + } } } else if (encryptedEventContent?.relatesTo?.type == RelationType.ANNOTATION) { // Reaction @@ -188,6 +208,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } } } + EventType.POLL_RESPONSE -> { + event.content.toModel(catchError = true)?.let { + handleResponse(realm, event, it, roomId, isLocalEcho) + } + } + EventType.POLL_END -> { + event.content.toModel(catchError = true)?.let { + handleEndPoll(realm, event, it, roomId, isLocalEcho) + } + } else -> Timber.v("UnHandled event ${event.eventId}") } } catch (t: Throwable) { @@ -276,7 +306,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( private fun handleResponse(realm: Realm, event: Event, - content: MessageContent, + content: MessagePollResponseContent, roomId: String, isLocalEcho: Boolean, relatedEventId: String? = null) { @@ -321,11 +351,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( 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 { + val option = content.response?.answers?.first() ?: return Unit.also { Timber.d("## POLL Ignoring malformed response no option eventId:$eventId content: ${event.content}") } @@ -336,22 +362,36 @@ internal class EventRelationsAggregationProcessor @Inject constructor( val existingVote = votes[existingVoteIndex] if (existingVote.voteTimestamp < eventTimestamp) { // Take the new one - votes[existingVoteIndex] = VoteInfo(senderId, optionIndex, eventTimestamp) + votes[existingVoteIndex] = VoteInfo(senderId, option, eventTimestamp) if (userId == senderId) { - sumModel.myVote = optionIndex + sumModel.myVote = option } - Timber.v("## POLL adding vote $optionIndex for user $senderId in poll :$targetEventId ") + Timber.v("## POLL adding vote $option for user $senderId in poll :$targetEventId ") } else { Timber.v("## POLL Ignoring vote (older than known one) eventId:$eventId ") } } else { - votes.add(VoteInfo(senderId, optionIndex, eventTimestamp)) + votes.add(VoteInfo(senderId, option, eventTimestamp)) if (userId == senderId) { - sumModel.myVote = optionIndex + sumModel.myVote = option } - Timber.v("## POLL adding vote $optionIndex for user $senderId in poll :$targetEventId ") + Timber.v("## POLL adding vote $option for user $senderId in poll :$targetEventId ") } sumModel.votes = votes + + // Precompute the percentage of votes for all options + val totalVotes = votes.size + sumModel.totalVotes = totalVotes + sumModel.votesSummary = votes + .groupBy({ it.option }, { it.userId }) + .mapValues { + VoteSummary( + total = it.value.size, + percentage = if (totalVotes == 0 && it.value.isEmpty()) 0.0 else it.value.size.toDouble() / totalVotes + ) + } + sumModel.winnerVoteCount = sumModel.votesSummary?.maxOf { it.value.total } ?: 0 + if (isLocalEcho) { existingPollSummary.sourceLocalEchoEvents.add(eventId) } else { @@ -361,6 +401,51 @@ internal class EventRelationsAggregationProcessor @Inject constructor( existingPollSummary.aggregatedContent = ContentMapper.map(sumModel.toContent()) } + private fun handleEndPoll(realm: Realm, + event: Event, + content: MessageEndPollContent, + roomId: String, + isLocalEcho: Boolean) { + val pollEventId = content.relatesTo?.eventId ?: return + + var existing = EventAnnotationsSummaryEntity.where(realm, roomId, pollEventId).findFirst() + if (existing == null) { + Timber.v("## POLL creating new relation summary for $pollEventId") + existing = EventAnnotationsSummaryEntity.create(realm, roomId, pollEventId) + } + + // we have it + val existingPollSummary = existing.pollResponseSummary + ?: realm.createObject(PollResponseAggregatedSummaryEntity::class.java).also { + existing.pollResponseSummary = it + } + + if (existingPollSummary.closedTime != null) { + Timber.v("## Received poll.end event for already ended poll $pollEventId") + return + } + + val powerLevelsHelper = stateEventDataSource.getStateEvent(roomId, EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.NoCondition) + ?.content?.toModel() + ?.let { PowerLevelsHelper(it) } + if (!powerLevelsHelper?.isUserAbleToRedact(event.senderId ?: "").orFalse()) { + Timber.v("## Received poll.end event $pollEventId but user ${event.senderId} doesn't have enough power level in room $roomId") + 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:$pollEventId") + existingPollSummary.sourceLocalEchoEvents.remove(txId) + existingPollSummary.sourceEvents.add(event.eventId) + return + } + + existingPollSummary.closedTime = event.originServerTs + } + private fun handleInitialAggregatedRelations(realm: Realm, event: Event, roomId: String, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt index 23b7767816..5ae4007c63 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/prune/RedactionEventProcessor.kt @@ -70,7 +70,8 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr } else { when (typeToPrune) { EventType.ENCRYPTED, - EventType.MESSAGE -> { + EventType.MESSAGE, + EventType.POLL_START -> { Timber.d("REDACTION for message ${eventToPrune.eventId}") val unsignedData = EventMapper.map(eventToPrune).unsignedData ?: UnsignedData(null, null) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt index 77aadef6bd..d3162aef79 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt @@ -103,8 +103,14 @@ internal class DefaultSendService @AssistedInject constructor( .let { sendEvent(it) } } - override fun sendOptionsReply(pollEventId: String, optionIndex: Int, optionValue: String): Cancelable { - return localEchoEventFactory.createOptionsReplyEvent(roomId, pollEventId, optionIndex, optionValue) + override fun voteToPoll(pollEventId: String, answerId: String): Cancelable { + return localEchoEventFactory.createPollReplyEvent(roomId, pollEventId, answerId) + .also { createLocalEcho(it) } + .let { sendEvent(it) } + } + + override fun endPoll(pollEventId: String): Cancelable { + return localEchoEventFactory.createEndPollEvent(roomId, pollEventId) .also { createLocalEcho(it) } .let { sendEvent(it) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index a31d0cdec3..85b22628d7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt @@ -35,6 +35,7 @@ import org.matrix.android.sdk.api.session.room.model.message.ImageInfo import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody +import org.matrix.android.sdk.api.session.room.model.message.MessageEndPollContent import org.matrix.android.sdk.api.session.room.model.message.MessageFileContent import org.matrix.android.sdk.api.session.room.model.message.MessageFormat import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent @@ -46,6 +47,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent import org.matrix.android.sdk.api.session.room.model.message.PollAnswer import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo import org.matrix.android.sdk.api.session.room.model.message.PollQuestion +import org.matrix.android.sdk.api.session.room.model.message.PollResponse import org.matrix.android.sdk.api.session.room.model.message.ThumbnailInfo import org.matrix.android.sdk.api.session.room.model.message.VideoInfo import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent @@ -122,19 +124,28 @@ internal class LocalEchoEventFactory @Inject constructor( )) } - fun createOptionsReplyEvent(roomId: String, - pollEventId: String, - optionIndex: Int, - optionLabel: String): Event { - return createMessageEvent(roomId, - MessagePollResponseContent( - body = optionLabel, - relatesTo = RelationDefaultContent( - type = RelationType.RESPONSE, - option = optionIndex, - eventId = pollEventId) + fun createPollReplyEvent(roomId: String, + pollEventId: String, + answerId: String): Event { + val content = MessagePollResponseContent( + body = answerId, + relatesTo = RelationDefaultContent( + type = RelationType.REFERENCE, + eventId = pollEventId), + response = PollResponse( + answers = listOf(answerId) + ) - )) + ) + val localId = LocalEcho.createLocalEchoId() + return Event( + roomId = roomId, + originServerTs = dummyOriginServerTs(), + senderId = userId, + eventId = localId, + type = EventType.POLL_RESPONSE, + content = content.toContent(), + unsignedData = UnsignedData(age = null, transactionId = localId)) } fun createPollEvent(roomId: String, @@ -147,7 +158,7 @@ internal class LocalEchoEventFactory @Inject constructor( ), answers = options.mapIndexed { index, option -> PollAnswer( - id = index.toString(), + id = "$index-$option", answer = option ) } @@ -164,6 +175,25 @@ internal class LocalEchoEventFactory @Inject constructor( unsignedData = UnsignedData(age = null, transactionId = localId)) } + fun createEndPollEvent(roomId: String, + eventId: String): Event { + val content = MessageEndPollContent( + relatesTo = RelationDefaultContent( + type = RelationType.REFERENCE, + eventId = eventId + ) + ) + val localId = LocalEcho.createLocalEchoId() + return Event( + roomId = roomId, + originServerTs = dummyOriginServerTs(), + senderId = userId, + eventId = localId, + type = EventType.POLL_END, + content = content.toContent(), + unsignedData = UnsignedData(age = null, transactionId = localId)) + } + fun createReplaceTextOfReply(roomId: String, eventReplaced: TimelineEvent, originalEvent: TimelineEvent, @@ -413,7 +443,7 @@ internal class LocalEchoEventFactory @Inject constructor( when (content?.msgType) { MessageType.MSGTYPE_EMOTE, MessageType.MSGTYPE_TEXT, - MessageType.MSGTYPE_NOTICE -> { + MessageType.MSGTYPE_NOTICE -> { var formattedText: String? = null if (content is MessageContentWithFormattedBody) { formattedText = content.matrixFormattedBody @@ -424,11 +454,12 @@ internal class LocalEchoEventFactory @Inject constructor( TextContent(content.body, formattedText) } } - MessageType.MSGTYPE_FILE -> return TextContent("sent a file.") - MessageType.MSGTYPE_AUDIO -> return TextContent("sent an audio file.") - MessageType.MSGTYPE_IMAGE -> return TextContent("sent an image.") - MessageType.MSGTYPE_VIDEO -> return TextContent("sent a video.") - else -> return TextContent(content?.body ?: "") + MessageType.MSGTYPE_FILE -> return TextContent("sent a file.") + MessageType.MSGTYPE_AUDIO -> return TextContent("sent an audio file.") + MessageType.MSGTYPE_IMAGE -> return TextContent("sent an image.") + MessageType.MSGTYPE_VIDEO -> return TextContent("sent a video.") + MessageType.MSGTYPE_POLL_START -> return TextContent((content as? MessagePollContent)?.pollCreationInfo?.question?.question ?: "") + else -> return TextContent(content?.body ?: "") } } diff --git a/vector/sampledata/poll.json b/vector/sampledata/poll.json new file mode 100644 index 0000000000..45fdf47b83 --- /dev/null +++ b/vector/sampledata/poll.json @@ -0,0 +1,22 @@ +{ + "question": "What type of food should we have at the party?", + "data": [ + { + "answer": "Italian \uD83C\uDDEE\uD83C\uDDF9", + "votes": "9 votes" + }, + { + "answer": "Chinese \uD83C\uDDE8\uD83C\uDDF3", + "votes": "1 vote" + }, + { + "answer": "Brazilian \uD83C\uDDE7\uD83C\uDDF7", + "votes": "0 votes" + }, + { + "answer": "French \uD83C\uDDEB\uD83C\uDDF7", + "votes": "15 votes" + } + ], + "totalVotes": "Based on 20 votes" +} diff --git a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt index 43ff186e99..b1df29ebc6 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt @@ -21,8 +21,8 @@ import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent fun TimelineEvent.canReact(): Boolean { - // Only event of type EventType.MESSAGE or EventType.STICKER are supported for the moment - return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER) && + // Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment + return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER, EventType.POLL_START) && root.sendState == SendState.SYNCED && !root.isRedacted() } diff --git a/vector/src/main/java/im/vector/app/core/resources/UserPreferencesProvider.kt b/vector/src/main/java/im/vector/app/core/resources/UserPreferencesProvider.kt index 9ab3b9bf45..e7cabd1540 100644 --- a/vector/src/main/java/im/vector/app/core/resources/UserPreferencesProvider.kt +++ b/vector/src/main/java/im/vector/app/core/resources/UserPreferencesProvider.kt @@ -48,4 +48,8 @@ class UserPreferencesProvider @Inject constructor(private val vectorPreferences: fun shouldShowAvatarDisplayNameChanges(): Boolean { return vectorPreferences.showAvatarDisplayNameChangeMessages() } + + fun shouldShowPolls(): Boolean { + return vectorPreferences.labsEnablePolls() + } } diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt index 6c349d18dc..ccc07ef118 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt @@ -34,6 +34,7 @@ import android.widget.ImageButton import android.widget.LinearLayout import android.widget.PopupWindow import androidx.core.view.doOnNextLayout +import androidx.core.view.isVisible import com.amulyakhare.textdrawable.TextDrawable import com.amulyakhare.textdrawable.util.ColorGenerator import im.vector.app.R @@ -121,6 +122,20 @@ class AttachmentTypeSelectorView(context: Context, } } + fun setAttachmentVisibility(type: Type, isVisible: Boolean) { + when (type) { + Type.CAMERA -> views.attachmentCameraButtonContainer + Type.GALLERY -> views.attachmentGalleryButtonContainer + Type.FILE -> views.attachmentFileButtonContainer + Type.STICKER -> views.attachmentStickersButtonContainer + Type.AUDIO -> views.attachmentAudioButtonContainer + Type.CONTACT -> views.attachmentContactButtonContainer + Type.POLL -> views.attachmentPollButtonContainer + }.let { + it.isVisible = isVisible + } + } + private fun animateButtonIn(button: View, delay: Int) { val animation = AnimationSet(true) val scale = ScaleAnimation(0.0f, 1.0f, 0.0f, 1.0f, Animation.RELATIVE_TO_SELF, 0.5f, Animation.RELATIVE_TO_SELF, 0.0f) diff --git a/vector/src/main/java/im/vector/app/features/form/FormEditTextWithDeleteItem.kt b/vector/src/main/java/im/vector/app/features/form/FormEditTextWithDeleteItem.kt index abcd1429d4..09ca51cfb1 100644 --- a/vector/src/main/java/im/vector/app/features/form/FormEditTextWithDeleteItem.kt +++ b/vector/src/main/java/im/vector/app/features/form/FormEditTextWithDeleteItem.kt @@ -17,6 +17,7 @@ package im.vector.app.features.form import android.text.Editable +import android.text.InputFilter import android.view.inputmethod.EditorInfo import android.widget.ImageButton import com.airbnb.epoxy.EpoxyAttribute @@ -51,6 +52,9 @@ abstract class FormEditTextWithDeleteItem : VectorEpoxyModel roomDetailViewModel.handle(RoomDetailAction.RedactAction(action.eventId, reason)) } @@ -2059,9 +2063,23 @@ class RoomDetailFragment @Inject constructor( startActivity(KeysBackupRestoreActivity.intent(it)) } } + is EventSharedAction.EndPoll -> { + askConfirmationToEndPoll(action.eventId) + } } } + private fun askConfirmationToEndPoll(eventId: String) { + MaterialAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_Vector_MaterialAlertDialog) + .setTitle(R.string.end_poll_confirmation_title) + .setMessage(R.string.end_poll_confirmation_description) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.end_poll_confirmation_approve_button) { _, _ -> + roomDetailViewModel.handle(RoomDetailAction.EndPoll(eventId)) + } + .show() + } + private fun askConfirmationToIgnoreUser(senderId: String) { MaterialAlertDialogBuilder(requireContext(), R.style.ThemeOverlay_Vector_MaterialAlertDialog_Destructive) .setTitle(R.string.room_participants_action_ignore_title) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index 102f241692..f438c6e1e4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -289,7 +289,7 @@ class RoomDetailViewModel @AssistedInject constructor( is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) is RoomDetailAction.EnterTrackingUnreadMessagesState -> startTrackingUnreadMessages() is RoomDetailAction.ExitTrackingUnreadMessagesState -> stopTrackingUnreadMessages() - is RoomDetailAction.ReplyToOptions -> handleReplyToOptions(action) + is RoomDetailAction.VoteToPoll -> handleVoteToPoll(action) is RoomDetailAction.AcceptVerificationRequest -> handleAcceptVerification(action) is RoomDetailAction.DeclineVerificationRequest -> handleDeclineVerification(action) is RoomDetailAction.RequestVerification -> handleRequestVerification(action) @@ -329,6 +329,7 @@ class RoomDetailViewModel @AssistedInject constructor( } _viewEvents.post(RoomDetailViewEvents.OpenRoom(action.replacementRoomId, closeCurrentRoom = true)) } + is RoomDetailAction.EndPoll -> handleEndPoll(action.eventId) }.exhaustive } @@ -907,10 +908,20 @@ class RoomDetailViewModel @AssistedInject constructor( } } - private fun handleReplyToOptions(action: RoomDetailAction.ReplyToOptions) { - // Do not allow to reply to unsent local echo + private fun handleVoteToPoll(action: RoomDetailAction.VoteToPoll) { + // Do not allow to vote unsent local echo of the poll event if (LocalEcho.isLocalEchoId(action.eventId)) return - room.sendOptionsReply(action.eventId, action.optionIndex, action.optionValue) + // Do not allow to vote the same option twice + room.getTimeLineEvent(action.eventId)?.let { pollTimelineEvent -> + val currentVote = pollTimelineEvent.annotations?.pollResponseSummary?.aggregatedContent?.myVote + if (currentVote != action.optionKey) { + room.voteToPoll(action.eventId, action.optionKey) + } + } + } + + private fun handleEndPoll(eventId: String) { + room.endPoll(eventId) } private fun observeSyncState() { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt index d9ee7f3ccf..30d69d6533 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/EventSharedAction.kt @@ -60,7 +60,7 @@ sealed class EventSharedAction(@StringRes val titleRes: Int, data class Remove(val eventId: String) : EventSharedAction(R.string.remove, R.drawable.ic_trash, true) - data class Redact(val eventId: String, val askForReason: Boolean) : + data class Redact(val eventId: String, val askForReason: Boolean, val dialogTitleRes: Int, val dialogDescriptionRes: Int) : EventSharedAction(R.string.message_action_item_redact, R.drawable.ic_delete, true) data class Cancel(val eventId: String, val force: Boolean) : @@ -112,4 +112,7 @@ sealed class EventSharedAction(@StringRes val titleRes: Int, object UseKeyBackup : EventSharedAction(R.string.e2e_use_keybackup, R.drawable.shield) + + data class EndPoll(val eventId: String) : + EventSharedAction(R.string.poll_end_action, R.drawable.ic_check_on) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index 30c231131e..ff7d555ee3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -49,6 +49,7 @@ import org.matrix.android.sdk.api.session.events.model.isTextMessage import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageFormat +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent @@ -206,6 +207,9 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted EventType.CALL_ANSWER -> { noticeEventFormatter.format(timelineEvent, room?.roomSummary()?.isDirect.orFalse()) } + EventType.POLL_START -> { + timelineEvent.root.getClearContent().toModel(catchError = true)?.pollCreationInfo?.question?.question ?: "" + } else -> null } } @@ -320,12 +324,30 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted add(EventSharedAction.Reply(eventId)) } + if (canEndPoll(timelineEvent, actionPermissions)) { + add(EventSharedAction.EndPoll(timelineEvent.eventId)) + } + if (canEdit(timelineEvent, session.myUserId, actionPermissions)) { add(EventSharedAction.Edit(eventId)) } if (canRedact(timelineEvent, actionPermissions)) { - add(EventSharedAction.Redact(eventId, askForReason = informationData.senderId != session.myUserId)) + if (timelineEvent.root.getClearType() == EventType.POLL_START) { + add(EventSharedAction.Redact( + eventId, + askForReason = informationData.senderId != session.myUserId, + dialogTitleRes = R.string.delete_poll_dialog_title, + dialogDescriptionRes = R.string.delete_poll_dialog_content + )) + } else { + add(EventSharedAction.Redact( + eventId, + askForReason = informationData.senderId != session.myUserId, + dialogTitleRes = R.string.delete_event_dialog_title, + dialogDescriptionRes = R.string.delete_event_dialog_content + )) + } } if (canCopy(msgType)) { @@ -391,8 +413,8 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted } private fun canReply(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean { - // Only event of type EventType.MESSAGE are supported for the moment - if (event.root.getClearType() != EventType.MESSAGE) return false + // Only EventType.MESSAGE and EventType.POLL_START event types are supported for the moment + if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.POLL_START)) return false if (!actionPermissions.canSendMessage) return false return when (messageContent?.msgType) { MessageType.MSGTYPE_TEXT, @@ -401,8 +423,9 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted MessageType.MSGTYPE_IMAGE, MessageType.MSGTYPE_VIDEO, MessageType.MSGTYPE_AUDIO, - MessageType.MSGTYPE_FILE -> true - else -> false + MessageType.MSGTYPE_FILE, + MessageType.MSGTYPE_POLL_START -> true + else -> false } } @@ -422,8 +445,8 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted } private fun canRedact(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean { - // Only event of type EventType.MESSAGE or EventType.STICKER are supported for the moment - if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER)) return false + // Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment + if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER, EventType.POLL_START)) return false // Message sent by the current user can always be redacted if (event.root.senderId == session.myUserId) return true // Check permission for messages sent by other users @@ -437,8 +460,8 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted } private fun canViewReactions(event: TimelineEvent): Boolean { - // Only event of type EventType.MESSAGE and EventType.STICKER are supported for the moment - if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER)) return false + // Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment + if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER, EventType.POLL_START)) return false return event.annotations?.reactionsSummary?.isNotEmpty() ?: false } @@ -487,4 +510,10 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted else -> false } } + + private fun canEndPoll(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean { + return event.root.getClearType() == EventType.POLL_START && + canRedact(event, actionPermissions) && + event.annotations?.pollResponseSummary?.closedTime == null + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 98deaaf9c3..35d92b0a23 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -48,12 +48,13 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageImageVideoItem import im.vector.app.features.home.room.detail.timeline.item.MessageImageVideoItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData -import im.vector.app.features.home.room.detail.timeline.item.MessageOptionsItem_ -import im.vector.app.features.home.room.detail.timeline.item.MessagePollItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem import im.vector.app.features.home.room.detail.timeline.item.MessageTextItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem_ +import im.vector.app.features.home.room.detail.timeline.item.PollItem +import im.vector.app.features.home.room.detail.timeline.item.PollItem_ +import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem_ import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem @@ -70,6 +71,7 @@ import im.vector.app.features.media.VideoContentRenderer import me.gujun.android.span.span import org.commonmark.node.Document import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.toModel @@ -80,14 +82,11 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageEmoteContent import org.matrix.android.sdk.api.session.room.model.message.MessageFileContent import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent import org.matrix.android.sdk.api.session.room.model.message.MessageNoticeContent -import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent -import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent -import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS -import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_POLL import org.matrix.android.sdk.api.session.room.model.message.getFileName import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl @@ -168,41 +167,67 @@ class MessageItemFactory @Inject constructor( } } is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes) - is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes) - is MessagePollResponseContent -> noticeItemFactory.create(params) + is MessagePollContent -> buildPollContent(messageContent, informationData, highlight, callback, attributes) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } } - private fun buildOptionsMessageItem(messageContent: MessageOptionsContent, - informationData: MessageInformationData, - highlight: Boolean, - callback: TimelineEventController.Callback?, - attributes: AbsMessageItem.Attributes): VectorEpoxyModel<*>? { - return when (messageContent.optionType) { - OPTION_TYPE_POLL -> { - MessagePollItem_() - .attributes(attributes) - .callback(callback) - .informationData(informationData) - .leftGuideline(avatarSizeProvider.leftGuideline) - .optionsContent(messageContent) - .highlighted(highlight) - } - OPTION_TYPE_BUTTONS -> { - MessageOptionsItem_() - .attributes(attributes) - .callback(callback) - .informationData(informationData) - .leftGuideline(avatarSizeProvider.leftGuideline) - .optionsContent(messageContent) - .highlighted(highlight) - } - else -> { - // Not supported optionType - buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) + private fun buildPollContent(pollContent: MessagePollContent, + informationData: MessageInformationData, + highlight: Boolean, + callback: TimelineEventController.Callback?, + attributes: AbsMessageItem.Attributes): PollItem? { + val optionViewStates = mutableListOf() + + val pollResponseSummary = informationData.pollResponseAggregatedSummary + val isEnded = pollResponseSummary?.isClosed.orFalse() + val didUserVoted = pollResponseSummary?.myVote?.isNotEmpty().orFalse() + val winnerVoteCount = pollResponseSummary?.winnerVoteCount + val isPollSent = informationData.sendState.isSent() + val totalVotesText = (pollResponseSummary?.totalVotes ?: 0).let { + when { + isEnded -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, it, it) + didUserVoted -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, it, it) + else -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_not_voted, it, it) } } + + pollContent.pollCreationInfo?.answers?.forEach { option -> + val voteSummary = pollResponseSummary?.votes?.get(option.id) + val isMyVote = pollResponseSummary?.myVote == option.id + val voteCount = voteSummary?.total ?: 0 + val votePercentage = voteSummary?.percentage ?: 0.0 + val optionId = option.id ?: "" + val optionAnswer = option.answer ?: "" + + optionViewStates.add( + if (!isPollSent) { + // Poll event is not send yet. Disable option. + PollOptionViewState.PollSending(optionId, optionAnswer) + } else if (isEnded) { + // Poll is ended. Disable option, show votes and mark the winner. + val isWinner = winnerVoteCount != 0 && voteCount == winnerVoteCount + PollOptionViewState.PollEnded(optionId, optionAnswer, voteCount, votePercentage, isWinner) + } else if (didUserVoted) { + // User voted to the poll, but poll is not ended. Enable option, show votes and mark the user's selection. + PollOptionViewState.PollVoted(optionId, optionAnswer, voteCount, votePercentage, isMyVote) + } else { + // User didn't voted yet and poll is not ended yet. Enable options, hide votes. + PollOptionViewState.PollReady(optionId, optionAnswer) + } + ) + } + + return PollItem_() + .attributes(attributes) + .eventId(informationData.eventId) + .pollQuestion(pollContent.pollCreationInfo?.question?.question ?: "") + .pollSent(isPollSent) + .totalVotesText(totalVotesText) + .optionViewStates(optionViewStates) + .highlighted(highlight) + .leftGuideline(avatarSizeProvider.leftGuideline) + .callback(callback) } private fun buildAudioMessageItem(messageContent: MessageAudioContent, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index c21fe935bb..c6b128315f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -48,6 +48,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me when (event.root.getClearType()) { // Message itemsX EventType.STICKER, + EventType.POLL_START, EventType.MESSAGE -> messageItemFactory.create(params) EventType.STATE_ROOM_TOMBSTONE, EventType.STATE_ROOM_NAME, @@ -74,7 +75,9 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me EventType.REACTION, EventType.STATE_SPACE_CHILD, EventType.STATE_SPACE_PARENT, - EventType.STATE_ROOM_POWER_LEVELS -> noticeItemFactory.create(params) + EventType.STATE_ROOM_POWER_LEVELS, + EventType.POLL_RESPONSE, + EventType.POLL_END -> noticeItemFactory.create(params) EventType.STATE_ROOM_WIDGET_LEGACY, EventType.STATE_ROOM_WIDGET -> widgetItemFactory.create(params) EventType.STATE_ROOM_ENCRYPTION -> encryptionItemFactory.create(params) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt index 624f22e09b..3616367e7d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt @@ -27,10 +27,9 @@ import org.commonmark.node.Document import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent -import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageType -import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent @@ -88,26 +87,7 @@ class DisplayableEventFormatter @Inject constructor( simpleFormat(senderName, stringProvider.getString(R.string.sent_a_video), appendAuthor) } MessageType.MSGTYPE_FILE -> { - return simpleFormat(senderName, stringProvider.getString(R.string.sent_a_file), appendAuthor) - } - MessageType.MSGTYPE_RESPONSE -> { - // do not show that? - span { } - } - MessageType.MSGTYPE_OPTIONS -> { - when (messageContent) { - is MessageOptionsContent -> { - val previewText = if (messageContent.optionType == OPTION_TYPE_BUTTONS) { - stringProvider.getString(R.string.sent_a_bot_buttons) - } else { - stringProvider.getString(R.string.sent_a_poll) - } - simpleFormat(senderName, previewText, appendAuthor) - } - else -> { - span { } - } - } + simpleFormat(senderName, stringProvider.getString(R.string.sent_a_file), appendAuthor) } else -> { simpleFormat(senderName, messageContent.body, appendAuthor) @@ -137,6 +117,16 @@ class DisplayableEventFormatter @Inject constructor( EventType.CALL_CANDIDATES -> { span { } } + EventType.POLL_START -> { + timelineEvent.root.getClearContent().toModel(catchError = true)?.pollCreationInfo?.question?.question + ?: stringProvider.getString(R.string.sent_a_poll) + } + EventType.POLL_RESPONSE -> { + stringProvider.getString(R.string.poll_response_room_list_preview) + } + EventType.POLL_END -> { + stringProvider.getString(R.string.poll_end_room_list_preview) + } else -> { span { text = noticeEventFormatter.format(timelineEvent, isDm) ?: "" diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index 4ca1557208..3dc46c9d70 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -103,7 +103,9 @@ class NoticeEventFormatter @Inject constructor( EventType.KEY_VERIFICATION_READY, EventType.STATE_SPACE_CHILD, EventType.STATE_SPACE_PARENT, - EventType.REDACTION -> formatDebug(timelineEvent.root) + EventType.REDACTION, + EventType.POLL_RESPONSE, + EventType.POLL_END -> formatDebug(timelineEvent.root) else -> { Timber.v("Type $type not handled by this formatter") null diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index 6385494fe1..b30286163e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -23,6 +23,7 @@ import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFact import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData import im.vector.app.features.home.room.detail.timeline.item.PollResponseData +import im.vector.app.features.home.room.detail.timeline.item.PollVoteSummaryData import im.vector.app.features.home.room.detail.timeline.item.ReactionInfoData import im.vector.app.features.home.room.detail.timeline.item.ReferencesInfoData import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration @@ -107,10 +108,15 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses 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 } + isClosed = it.closedTime != null, + votes = it.aggregatedContent?.votesSummary?.mapValues { votesSummary -> + PollVoteSummaryData( + total = votesSummary.value.total, + percentage = votesSummary.value.percentage + ) + }, + winnerVoteCount = it.aggregatedContent?.winnerVoteCount ?: 0, + totalVotes = it.aggregatedContent?.totalVotes ?: 0 ) }, hasBeenEdited = event.hasBeenEdited(), diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index 053b804a82..bcccbc9f7c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -50,7 +50,8 @@ object TimelineDisplayableEvents { EventType.STATE_ROOM_TOMBSTONE, EventType.STATE_ROOM_JOIN_RULES, EventType.KEY_VERIFICATION_DONE, - EventType.KEY_VERIFICATION_CANCEL + EventType.KEY_VERIFICATION_CANCEL, + EventType.POLL_START ) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt index 580d7d18cf..01a92decc9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt @@ -119,6 +119,8 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen val diff = computeMembershipDiff() if ((diff.isJoin || diff.isPart) && !userPreferencesProvider.shouldShowJoinLeaves()) return true if ((diff.isAvatarChange || diff.isDisplaynameChange) && !userPreferencesProvider.shouldShowAvatarDisplayNameChanges()) return true + } else if (root.getClearType() == EventType.POLL_START && !userPreferencesProvider.shouldShowPolls()) { + return true } return false } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/EventItemAttributes.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/EventItemAttributes.kt deleted file mode 100644 index 0762c4952f..0000000000 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/EventItemAttributes.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2019 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.home.room.detail.timeline.item diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt index 08aa301538..8258f797f1 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt @@ -71,11 +71,19 @@ data class ReadReceiptData( @Parcelize data class PollResponseData( - val myVote: Int?, - val votes: Map?, + val myVote: String?, + val votes: Map?, + val totalVotes: Int = 0, + val winnerVoteCount: Int = 0, val isClosed: Boolean = false ) : Parcelable +@Parcelize +data class PollVoteSummaryData( + val total: Int = 0, + val percentage: Double = 0.0 +) : Parcelable + enum class E2EDecoration { NONE, WARN_IN_CLEAR, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageOptionsItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageOptionsItem.kt deleted file mode 100644 index 35da0d4291..0000000000 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageOptionsItem.kt +++ /dev/null @@ -1,79 +0,0 @@ -/* - * 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.app.features.home.room.detail.timeline.item - -import android.view.LayoutInflater -import android.view.ViewGroup -import android.widget.TextView -import com.airbnb.epoxy.EpoxyAttribute -import com.airbnb.epoxy.EpoxyModelClass -import com.google.android.material.button.MaterialButton -import im.vector.app.R -import im.vector.app.core.epoxy.onClick -import im.vector.app.core.extensions.setTextOrHide -import im.vector.app.features.home.room.detail.RoomDetailAction -import im.vector.app.features.home.room.detail.timeline.TimelineEventController -import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent - -@EpoxyModelClass(layout = R.layout.item_timeline_event_base) -abstract class MessageOptionsItem : AbsMessageItem() { - - @EpoxyAttribute - var optionsContent: MessageOptionsContent? = null - - @EpoxyAttribute - var callback: TimelineEventController.Callback? = null - - @EpoxyAttribute - var informationData: MessageInformationData? = null - - override fun getViewType() = STUB_ID - - override fun bind(holder: Holder) { - super.bind(holder) - - renderSendState(holder.view, holder.labelText) - - holder.labelText.setTextOrHide(optionsContent?.label) - - holder.buttonContainer.removeAllViews() - - val relatedEventId = informationData?.eventId ?: return - val options = optionsContent?.options?.takeIf { it.isNotEmpty() } ?: return - // Now add back the buttons - options.forEachIndexed { index, option -> - val materialButton = LayoutInflater.from(holder.view.context).inflate(R.layout.option_buttons, holder.buttonContainer, false) - as MaterialButton - holder.buttonContainer.addView(materialButton, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - materialButton.text = option.label - materialButton.onClick { - callback?.onTimelineItemAction(RoomDetailAction.ReplyToOptions(relatedEventId, index, option.value ?: "$index")) - } - } - } - - class Holder : AbsMessageItem.Holder(STUB_ID) { - - val labelText by bind(R.id.optionLabelText) - - val buttonContainer by bind(R.id.optionsButtonContainer) - } - - companion object { - private const val STUB_ID = R.id.messageOptionsStub - } -} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessagePollItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessagePollItem.kt deleted file mode 100644 index 2178843c28..0000000000 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessagePollItem.kt +++ /dev/null @@ -1,163 +0,0 @@ -/* - * 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.app.features.home.room.detail.timeline.item - -import android.view.View -import android.view.ViewGroup -import android.widget.Button -import android.widget.TextView -import androidx.core.view.isVisible -import com.airbnb.epoxy.EpoxyAttribute -import com.airbnb.epoxy.EpoxyModelClass -import im.vector.app.R -import im.vector.app.core.epoxy.ClickListener -import im.vector.app.core.epoxy.onClick -import im.vector.app.core.extensions.setTextOrHide -import im.vector.app.features.home.room.detail.RoomDetailAction -import im.vector.app.features.home.room.detail.timeline.TimelineEventController -import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent -import kotlin.math.roundToInt - -@EpoxyModelClass(layout = R.layout.item_timeline_event_base) -abstract class MessagePollItem : AbsMessageItem() { - - @EpoxyAttribute - var optionsContent: MessageOptionsContent? = null - - @EpoxyAttribute - var callback: TimelineEventController.Callback? = null - - @EpoxyAttribute - var informationData: MessageInformationData? = null - - override fun getViewType() = STUB_ID - - override fun bind(holder: Holder) { - super.bind(holder) - - holder.pollId = informationData?.eventId - holder.callback = callback - holder.optionValues = optionsContent?.options?.map { it.value ?: it.label } - - renderSendState(holder.view, holder.labelText) - - holder.labelText.setTextOrHide(optionsContent?.label) - - val buttons = listOf(holder.button1, holder.button2, holder.button3, holder.button4, holder.button5) - val resultLines = listOf(holder.result1, holder.result2, holder.result3, holder.result4, holder.result5) - - buttons.forEach { it.isVisible = false } - resultLines.forEach { it.isVisible = false } - - 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 - holder.resultWrapper.isVisible = false - optionsContent?.options?.forEachIndexed { index, item -> - if (index < buttons.size) { - buttons[index].let { - // current limitation, have to wait for event to be sent in order to reply - it.isEnabled = informationData?.sendState?.isSent() ?: false - it.text = item.label - it.isVisible = true - } - } - } - } else { - holder.resultWrapper.isVisible = true - val maxCount = votes?.maxByOrNull { 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, totalVotes, totalVotes) - } - - override fun unbind(holder: Holder) { - holder.pollId = null - holder.callback = null - holder.optionValues = null - super.unbind(holder) - } - - class Holder : AbsMessageItem.Holder(STUB_ID) { - - var pollId: String? = null - var optionValues: List? = null - var callback: TimelineEventController.Callback? = null - - val button1 by bind