Merge pull request #4624 from vector-im/feature/ons/poll_timeline

Poll Feature - Timeline
This commit is contained in:
Benoit Marty 2021-12-13 22:29:31 +01:00 committed by GitHub
commit 38e7e2fe4e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1035 additions and 663 deletions

1
changelog.d/4653.feature Normal file
View file

@ -0,0 +1 @@
Poll Feature - Render in timeline

View file

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

View file

@ -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<VoteInfo>? = null
) {
var votes: List<VoteInfo>? = null,
var votesSummary: Map<String, VoteSummary>? = 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
)

View file

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

View file

@ -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<OptionItem>? = null,
@Json(name = "m.new_content") override val newContent: Content? = null
) : MessageContent

View file

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

View file

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

View file

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

View file

@ -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<String>? = null
)

View file

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

View file

@ -32,6 +32,7 @@ object RoomSummaryConstants {
EventType.CALL_ANSWER,
EventType.ENCRYPTED,
EventType.STICKER,
EventType.REACTION
EventType.REACTION,
EventType.POLL_START
)
}

View file

@ -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<MessageStickerContent>()
} else {
(annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
return when (root.getClearType()) {
EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>()
EventType.POLL_START -> root.getClearContent().toModel<MessagePollContent>()
else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
}
}

View file

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

View file

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

View file

@ -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) {
} else if (event.getClearType() == EventType.POLL_RESPONSE) {
event.getClearContent().toModel<MessagePollResponseContent>(catchError = true)?.let { pollResponseContent ->
Timber.v("###RESPONSE in room $roomId for event ${event.eventId}")
handleResponse(realm, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.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<MessagePollResponseContent>(catchError = true)?.let {
handleResponse(realm, event, it, roomId, isLocalEcho, event.getRelationContent()?.eventId)
}
}
EventType.POLL_END -> {
event.content.toModel<MessageEndPollContent>(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<MessagePollResponseContent>(catchError = true)?.let {
handleResponse(realm, event, it, roomId, isLocalEcho)
}
}
EventType.POLL_END -> {
event.content.toModel<MessageEndPollContent>(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<MessagePollResponseContent>() ?: 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<PowerLevelsContent>()
?.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,

View file

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

View file

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

View file

@ -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,
fun createPollReplyEvent(roomId: String,
pollEventId: String,
optionIndex: Int,
optionLabel: String): Event {
return createMessageEvent(roomId,
MessagePollResponseContent(
body = optionLabel,
answerId: String): Event {
val content = MessagePollResponseContent(
body = answerId,
relatesTo = RelationDefaultContent(
type = RelationType.RESPONSE,
option = optionIndex,
eventId = pollEventId)
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,
@ -428,6 +458,7 @@ internal class LocalEchoEventFactory @Inject constructor(
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 ?: "")
}
}

View file

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

View file

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

View file

@ -48,4 +48,8 @@ class UserPreferencesProvider @Inject constructor(private val vectorPreferences:
fun shouldShowAvatarDisplayNameChanges(): Boolean {
return vectorPreferences.showAvatarDisplayNameChangeMessages()
}
fun shouldShowPolls(): Boolean {
return vectorPreferences.labsEnablePolls()
}
}

View file

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

View file

@ -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<FormEditTextWithDel
@EpoxyAttribute
var imeOptions: Int? = null
@EpoxyAttribute
var maxLength: Int? = null
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var onTextChange: TextListener? = null
@ -68,6 +72,12 @@ abstract class FormEditTextWithDeleteItem : VectorEpoxyModel<FormEditTextWithDel
holder.textInputLayout.isEnabled = enabled
holder.textInputLayout.hint = hint
if (maxLength != null) {
holder.textInputEditText.filters = arrayOf(InputFilter.LengthFilter(maxLength!!))
holder.textInputLayout.counterMaxLength = maxLength!!
} else {
holder.textInputEditText.filters = arrayOf()
}
holder.textInputEditText.setTextIfDifferent(value)
holder.textInputEditText.isEnabled = enabled

View file

@ -52,7 +52,7 @@ sealed class RoomDetailAction : VectorViewModelAction {
data class RemoveFailedEcho(val eventId: String) : RoomDetailAction()
data class CancelSend(val eventId: String, val force: Boolean) : RoomDetailAction()
data class ReplyToOptions(val eventId: String, val optionIndex: Int, val optionValue: String) : RoomDetailAction()
data class VoteToPoll(val eventId: String, val optionKey: String) : RoomDetailAction()
data class ReportContent(
val eventId: String,
@ -107,4 +107,7 @@ sealed class RoomDetailAction : VectorViewModelAction {
object RemoveAllFailedMessages : RoomDetailAction()
data class RoomUpgradeSuccess(val replacementRoomId: String) : RoomDetailAction()
// Poll
data class EndPoll(val eventId: String) : RoomDetailAction()
}

View file

@ -203,6 +203,7 @@ 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.MessageFormat
import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent
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.message.MessageVerificationRequestContent
@ -1077,6 +1078,8 @@ class RoomDetailFragment @Inject constructor(
val nonFormattedBody = if (messageContent is MessageAudioContent && messageContent.voiceMessageIndicator != null) {
val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong())
getString(R.string.voice_message_reply_content, formattedDuration)
} else if (messageContent is MessagePollContent) {
messageContent.pollCreationInfo?.question?.question
} else {
messageContent?.body ?: ""
}
@ -1362,6 +1365,7 @@ class RoomDetailFragment @Inject constructor(
override fun onAddAttachment() {
if (!::attachmentTypeSelector.isInitialized) {
attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@RoomDetailFragment)
attachmentTypeSelector.setAttachmentVisibility(AttachmentTypeSelectorView.Type.POLL, vectorPreferences.labsEnablePolls())
}
attachmentTypeSelector.show(views.composerLayout.views.attachmentButton, keyboardStateUtils.isKeyboardShowing)
}
@ -1576,10 +1580,10 @@ class RoomDetailFragment @Inject constructor(
.show(
activity = requireActivity(),
askForReason = action.askForReason,
confirmationRes = R.string.delete_event_dialog_content,
confirmationRes = action.dialogDescriptionRes,
positiveRes = R.string.remove,
reasonHintRes = R.string.delete_event_dialog_reason_hint,
titleRes = R.string.delete_event_dialog_title
titleRes = action.dialogTitleRes
) { reason ->
roomDetailViewModel.handle(RoomDetailAction.RedactAction(action.eventId, reason))
}
@ -2059,8 +2063,22 @@ 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)

View file

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

View file

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

View file

@ -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<MessagePollContent>(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,7 +423,8 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_FILE -> true
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
}
}

View file

@ -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,
private fun buildPollContent(pollContent: MessagePollContent,
informationData: MessageInformationData,
highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): VectorEpoxyModel<*>? {
return when (messageContent.optionType) {
OPTION_TYPE_POLL -> {
MessagePollItem_()
attributes: AbsMessageItem.Attributes): PollItem? {
val optionViewStates = mutableListOf<PollOptionViewState>()
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)
.callback(callback)
.informationData(informationData)
.leftGuideline(avatarSizeProvider.leftGuideline)
.optionsContent(messageContent)
.eventId(informationData.eventId)
.pollQuestion(pollContent.pollCreationInfo?.question?.question ?: "")
.pollSent(isPollSent)
.totalVotesText(totalVotesText)
.optionViewStates(optionViewStates)
.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)
}
}
.callback(callback)
}
private fun buildAudioMessageItem(messageContent: MessageAudioContent,

View file

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

View file

@ -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<MessagePollContent>(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) ?: ""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -71,11 +71,19 @@ data class ReadReceiptData(
@Parcelize
data class PollResponseData(
val myVote: Int?,
val votes: Map<Int, Int>?,
val myVote: String?,
val votes: Map<String, PollVoteSummaryData>?,
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,

View file

@ -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<MessageOptionsItem.Holder>() {
@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<TextView>(R.id.optionLabelText)
val buttonContainer by bind<ViewGroup>(R.id.optionsButtonContainer)
}
companion object {
private const val STUB_ID = R.id.messageOptionsStub
}
}

View file

@ -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<MessagePollItem.Holder>() {
@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<String?>? = null
var callback: TimelineEventController.Callback? = null
val button1 by bind<Button>(R.id.pollButton1)
val button2 by bind<Button>(R.id.pollButton2)
val button3 by bind<Button>(R.id.pollButton3)
val button4 by bind<Button>(R.id.pollButton4)
val button5 by bind<Button>(R.id.pollButton5)
val result1 by bind<PollResultLineView>(R.id.pollResult1)
val result2 by bind<PollResultLineView>(R.id.pollResult2)
val result3 by bind<PollResultLineView>(R.id.pollResult3)
val result4 by bind<PollResultLineView>(R.id.pollResult4)
val result5 by bind<PollResultLineView>(R.id.pollResult5)
val labelText by bind<TextView>(R.id.pollLabelText)
val infoText by bind<TextView>(R.id.pollInfosText)
val resultWrapper by bind<ViewGroup>(R.id.pollResultsWrapper)
override fun bindView(itemView: View) {
super.bindView(itemView)
val buttons = listOf(button1, button2, button3, button4, button5)
val clickListener = object : ClickListener {
override fun invoke(p1: View) {
val optionIndex = buttons.indexOf(p1)
if (optionIndex != -1 && pollId != null) {
val compatValue = if (optionIndex < optionValues?.size ?: 0) optionValues?.get(optionIndex) else null
callback?.onTimelineItemAction(RoomDetailAction.ReplyToOptions(pollId!!, optionIndex, compatValue ?: "$optionIndex"))
}
}
}
buttons.forEach { it.onClick(clickListener) }
}
}
companion object {
private const val STUB_ID = R.id.messagePollStub
}
}

View file

@ -0,0 +1,86 @@
/*
* 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.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.children
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.features.home.room.detail.RoomDetailAction
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class PollItem : AbsMessageItem<PollItem.Holder>() {
@EpoxyAttribute
var pollQuestion: String? = null
@EpoxyAttribute
var callback: TimelineEventController.Callback? = null
@EpoxyAttribute
var eventId: String? = null
@EpoxyAttribute
var pollSent: Boolean = false
@EpoxyAttribute
var totalVotesText: String? = null
@EpoxyAttribute
lateinit var optionViewStates: List<PollOptionViewState>
override fun bind(holder: Holder) {
super.bind(holder)
val relatedEventId = eventId ?: return
renderSendState(holder.view, holder.questionTextView)
holder.questionTextView.text = pollQuestion
holder.totalVotesTextView.text = totalVotesText
while (holder.optionsContainer.childCount < optionViewStates.size) {
holder.optionsContainer.addView(PollOptionView(holder.view.context))
}
while (holder.optionsContainer.childCount > optionViewStates.size) {
holder.optionsContainer.removeViewAt(0)
}
val views = holder.optionsContainer.children.toList().filterIsInstance<PollOptionView>()
optionViewStates.forEachIndexed { index, optionViewState ->
views.getOrNull(index)?.let {
it.render(optionViewState)
it.setOnClickListener {
callback?.onTimelineItemAction(RoomDetailAction.VoteToPoll(relatedEventId, optionViewState.optionId))
}
}
}
}
class Holder : AbsMessageItem.Holder(STUB_ID) {
val questionTextView by bind<TextView>(R.id.questionTextView)
val optionsContainer by bind<LinearLayout>(R.id.optionsContainer)
val totalVotesTextView by bind<TextView>(R.id.optionsTotalVotesTextView)
}
companion object {
private const val STUB_ID = R.id.messageContentPollStub
}
}

View file

@ -0,0 +1,112 @@
/*
* Copyright (c) 2021 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.content.Context
import android.os.Build
import android.util.AttributeSet
import androidx.appcompat.content.res.AppCompatResources
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.core.extensions.setAttributeTintedImageResource
import im.vector.app.databinding.ItemPollOptionBinding
class PollOptionView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
private val views: ItemPollOptionBinding
init {
inflate(context, R.layout.item_poll_option, this)
views = ItemPollOptionBinding.bind(this)
}
fun render(state: PollOptionViewState) {
views.optionNameTextView.text = state.optionAnswer
when (state) {
is PollOptionViewState.PollSending -> renderPollSending()
is PollOptionViewState.PollEnded -> renderPollEnded(state)
is PollOptionViewState.PollReady -> renderPollReady()
is PollOptionViewState.PollVoted -> renderPollVoted(state)
}
}
private fun renderPollSending() {
views.optionCheckImageView.isVisible = false
views.optionWinnerImageView.isVisible = false
hideVotes()
renderVoteSelection(false)
}
private fun renderPollEnded(state: PollOptionViewState.PollEnded) {
views.optionCheckImageView.isVisible = false
views.optionWinnerImageView.isVisible = state.isWinner
showVotes(state.voteCount, state.votePercentage)
renderVoteSelection(state.isWinner)
}
private fun renderPollReady() {
views.optionCheckImageView.isVisible = true
views.optionWinnerImageView.isVisible = false
hideVotes()
renderVoteSelection(false)
}
private fun renderPollVoted(state: PollOptionViewState.PollVoted) {
views.optionCheckImageView.isVisible = true
views.optionWinnerImageView.isVisible = false
showVotes(state.voteCount, state.votePercentage)
renderVoteSelection(state.isSelected)
}
private fun showVotes(voteCount: Int, votePercentage: Double) {
views.optionVoteCountTextView.apply {
isVisible = true
text = resources.getQuantityString(R.plurals.poll_option_vote_count, voteCount, voteCount)
}
views.optionVoteProgress.apply {
val progressValue = (votePercentage * 100).toInt()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
setProgress(progressValue, true)
} else {
progress = progressValue
}
}
}
private fun hideVotes() {
views.optionVoteCountTextView.isVisible = false
views.optionVoteProgress.progress = 0
}
private fun renderVoteSelection(isSelected: Boolean) {
if (isSelected) {
views.optionBorderImageView.setAttributeTintedImageResource(R.drawable.bg_poll_option, R.attr.colorPrimary)
views.optionVoteProgress.progressDrawable = AppCompatResources.getDrawable(context, R.drawable.poll_option_progressbar_checked)
views.optionCheckImageView.setImageResource(R.drawable.poll_option_checked)
} else {
views.optionBorderImageView.setAttributeTintedImageResource(R.drawable.bg_poll_option, R.attr.vctr_content_quinary)
views.optionVoteProgress.progressDrawable = AppCompatResources.getDrawable(context, R.drawable.poll_option_progressbar_unchecked)
views.optionCheckImageView.setImageResource(R.drawable.poll_option_unchecked)
}
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2021 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
sealed class PollOptionViewState(open val optionId: String,
open val optionAnswer: String) {
/**
* Represents a poll that is not sent to the server yet.
*/
data class PollSending(override val optionId: String,
override val optionAnswer: String
) : PollOptionViewState(optionId, optionAnswer)
/**
* Represents a poll that is sent but not voted by the user
*/
data class PollReady(override val optionId: String,
override val optionAnswer: String
) : PollOptionViewState(optionId, optionAnswer)
/**
* Represents a poll that user already voted.
*/
data class PollVoted(override val optionId: String,
override val optionAnswer: String,
val voteCount: Int,
val votePercentage: Double,
val isSelected: Boolean
) : PollOptionViewState(optionId, optionAnswer)
/**
* Represents a poll that is ended.
*/
data class PollEnded(override val optionId: String,
override val optionAnswer: String,
val voteCount: Int,
val votePercentage: Double,
val isWinner: Boolean
) : PollOptionViewState(optionId, optionAnswer)
}

View file

@ -60,7 +60,7 @@ class CreatePollController @Inject constructor(
hint(host.stringProvider.getString(R.string.create_poll_question_hint))
singleLine(true)
imeOptions(questionImeAction)
maxLength(500)
maxLength(340)
onTextChange {
host.callback?.onQuestionChanged(it)
}
@ -80,6 +80,7 @@ class CreatePollController @Inject constructor(
hint(host.stringProvider.getString(R.string.create_poll_options_hint, (index + 1)))
singleLine(true)
imeOptions(imeOptions)
maxLength(340)
onTextChange {
host.callback?.onOptionChanged(index, it)
}

View file

@ -197,6 +197,8 @@ class VectorPreferences @Inject constructor(private val context: Context) {
private const val TAKE_PHOTO_VIDEO_MODE = "TAKE_PHOTO_VIDEO_MODE"
private const val SETTINGS_LABS_ENABLE_POLLS = "SETTINGS_LABS_ENABLE_POLLS"
// Possible values for TAKE_PHOTO_VIDEO_MODE
const val TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK = 0
const val TAKE_PHOTO_VIDEO_MODE_PHOTO = 1
@ -1007,4 +1009,8 @@ class VectorPreferences @Inject constructor(private val context: Context) {
putInt(TAKE_PHOTO_VIDEO_MODE, mode)
}
}
fun labsEnablePolls(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_POLLS, false)
}
}

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/transparent" />
<stroke
android:width="1dp"
android:color="?vctr_content_quinary" />
<corners android:radius="4dp" />
</shape>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<size
android:width="1dp"
android:height="16dp" />
</shape>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:pathData="M20,7L9,18L4,13"
android:strokeWidth="2"
android:strokeColor="#FFFFFF"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M12.6667,3.3333H11.3333V2.6667C11.3333,2.3 11.0333,2 10.6667,2H5.3333C4.9667,2 4.6667,2.3 4.6667,2.6667V3.3333H3.3333C2.6,3.3333 2,3.9333 2,4.6667V5.3333C2,7.0333 3.28,8.42 4.9267,8.6267C5.3467,9.6267 6.2467,10.38 7.3333,10.6V12.6667H5.3333C4.9667,12.6667 4.6667,12.9667 4.6667,13.3333C4.6667,13.7 4.9667,14 5.3333,14H10.6667C11.0333,14 11.3333,13.7 11.3333,13.3333C11.3333,12.9667 11.0333,12.6667 10.6667,12.6667H8.6667V10.6C9.7533,10.38 10.6533,9.6267 11.0733,8.6267C12.72,8.42 14,7.0333 14,5.3333V4.6667C14,3.9333 13.4,3.3333 12.6667,3.3333ZM3.3333,5.3333V4.6667H4.6667V7.2133C3.8933,6.9333 3.3333,6.2 3.3333,5.3333ZM12.6667,5.3333C12.6667,6.2 12.1067,6.9333 11.3333,7.2133V4.6667H12.6667V5.3333Z"
android:fillColor="#0DBD8B"/>
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="?colorPrimary" />
<size
android:width="20dp"
android:height="20dp" />
</shape>
</item>
<item
android:drawable="@drawable/ic_check_on_white"
android:gravity="center" />
</layer-list>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="4dp" />
<solid android:color="?vctr_system" />
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="4dp" />
<solid android:color="?colorPrimary" />
</shape>
</clip>
</item>
</layer-list>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="4dp" />
<solid android:color="?vctr_system" />
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="4dp" />
<solid android:color="?vctr_content_quaternary" />
</shape>
</clip>
</item>
</layer-list>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@android:color/transparent" />
<stroke
android:width="1dp"
android:color="?vctr_content_quaternary" />
<size
android:width="20dp"
android:height="20dp" />
</shape>

View file

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/optionContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/optionBorderImageView"
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
android:src="@drawable/bg_poll_option"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/optionCheckImageView"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginStart="12dp"
android:layout_marginTop="16dp"
android:importantForAccessibility="no"
android:src="@drawable/poll_option_unchecked"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/optionNameTextView"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="12dp"
app:layout_constraintEnd_toEndOf="@id/optionWinnerImageView"
app:layout_constraintStart_toEndOf="@id/optionCheckImageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="@sample/poll.json/data/answer" />
<ImageView
android:id="@+id/optionWinnerImageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:contentDescription="@string/a11y_poll_winner_option"
android:src="@drawable/ic_poll_winner"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<TextView
android:id="@+id/optionVoteCountTextView"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/optionVoteProgress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/optionVoteProgress"
tools:text="@sample/poll.json/data/votes"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/optionVoteProgress"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="0dp"
android:layout_height="6dp"
android:layout_marginStart="8dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:progressDrawable="@drawable/poll_option_progressbar_checked"
app:layout_constraintBottom_toBottomOf="@id/optionBorderImageView"
app:layout_constraintEnd_toStartOf="@id/optionVoteCountTextView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/optionNameTextView"
tools:progress="60" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -119,24 +119,17 @@
android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_redacted_stub" />
<ViewStub
android:id="@+id/messagePollStub"
style="@style/TimelineContentStubBaseParams"
android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_poll_stub" />
<ViewStub
android:id="@+id/messageOptionsStub"
style="@style/TimelineContentStubBaseParams"
android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_option_buttons_stub" />
<ViewStub
android:id="@+id/messageContentVoiceStub"
style="@style/TimelineContentStubBaseParams"
android:layout="@layout/item_timeline_event_voice_stub"
tools:visibility="visible" />
<ViewStub
android:id="@+id/messageContentPollStub"
style="@style/TimelineContentStubBaseParams"
android:layout="@layout/item_timeline_event_poll" />
</FrameLayout>
<im.vector.app.core.ui.views.SendStateImageView

View file

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
tools:viewBindingIgnore="true">
<TextView
android:id="@+id/optionLabelText"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:textColor="?vctr_content_primary"
tools:text="What would you like to do?" />
<LinearLayout
android:id="@+id/optionsButtonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Filled at runtime with buttons -->
<!--Button
android:id="@+id/pollButton1"
style="@style/Widget.Vector.Button.Outlined.Poll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="Create Github issue" /-->
</LinearLayout>
</LinearLayout>

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/questionTextView"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="?vctr_content_primary"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="@sample/poll.json/question" />
<LinearLayout
android:id="@+id/optionsContainer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:divider="@drawable/divider_poll_options"
android:orientation="vertical"
android:showDividers="middle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/questionTextView" />
<TextView
android:id="@+id/optionsTotalVotesTextView"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/optionsContainer"
tools:text="@sample/poll.json/totalVotes" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/pollItemContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" />

View file

@ -1,147 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
tools:viewBindingIgnore="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_margin="4dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_poll"
app:tint="?colorPrimary"
tools:ignore="MissingPrefix" />
<TextView
android:id="@+id/pollLabelText"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginStart="4dp"
android:textColor="?vctr_content_primary"
android:textStyle="bold"
tools:text="What would you like to do?" />
</LinearLayout>
<Button
android:id="@+id/pollButton1"
style="@style/Widget.Vector.Button.Outlined.Poll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
tools:text="Create Github issue"
tools:visibility="visible" />
<Button
android:id="@+id/pollButton2"
style="@style/Widget.Vector.Button.Outlined.Poll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
tools:text="Search Github"
tools:visibility="visible" />
<Button
android:id="@+id/pollButton3"
style="@style/Widget.Vector.Button.Outlined.Poll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
tools:text="Logout"
tools:visibility="visible" />
<Button
android:id="@+id/pollButton4"
style="@style/Widget.Vector.Button.Outlined.Poll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
tools:text="Option 4"
tools:visibility="visible" />
<Button
android:id="@+id/pollButton5"
style="@style/Widget.Vector.Button.Outlined.Poll"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
tools:text="Option 5"
tools:visibility="visible" />
<LinearLayout
android:id="@+id/pollResultsWrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/bg_attachment_type_selector"
android:orientation="vertical"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:visibility="gone"
tools:visibility="visible">
<im.vector.app.features.home.room.detail.timeline.item.PollResultLineView
android:id="@+id/pollResult1"
android:layout_width="match_parent"
android:layout_height="40dp"
tools:optionCount="40%"
tools:optionName="Create Github issue"
tools:optionSelected="true" />
<im.vector.app.features.home.room.detail.timeline.item.PollResultLineView
android:id="@+id/pollResult2"
android:layout_width="match_parent"
android:layout_height="40dp"
tools:optionCount="60%"
tools:optionIsWinner="true"
tools:optionName="Search Github"
tools:optionSelected="false" />
<im.vector.app.features.home.room.detail.timeline.item.PollResultLineView
android:id="@+id/pollResult3"
android:layout_width="match_parent"
android:layout_height="40dp"
tools:optionCount="0%"
tools:optionName="Logout"
tools:optionSelected="false" />
<im.vector.app.features.home.room.detail.timeline.item.PollResultLineView
android:id="@+id/pollResult4"
android:layout_width="match_parent"
android:layout_height="40dp"
tools:optionCount="0%"
tools:optionName="Option 4"
tools:optionSelected="false" />
<im.vector.app.features.home.room.detail.timeline.item.PollResultLineView
android:id="@+id/pollResult5"
android:layout_width="match_parent"
android:layout_height="40dp"
tools:optionCount="0%"
tools:optionName="Option 5"
tools:optionSelected="false" />
</LinearLayout>
<TextView
android:id="@+id/pollInfosText"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="?vctr_content_secondary"
android:visibility="gone"
tools:text="12 votes - Final Results"
tools:visibility="visible" />
</LinearLayout>

View file

@ -24,6 +24,7 @@
android:weightSum="3">
<LinearLayout
android:id="@+id/attachmentCameraButtonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
@ -45,6 +46,7 @@
</LinearLayout>
<LinearLayout
android:id="@+id/attachmentGalleryButtonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
@ -66,6 +68,7 @@
</LinearLayout>
<LinearLayout
android:id="@+id/attachmentFileButtonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
@ -97,6 +100,7 @@
android:weightSum="3">
<LinearLayout
android:id="@+id/attachmentAudioButtonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
@ -118,6 +122,7 @@
</LinearLayout>
<LinearLayout
android:id="@+id/attachmentContactButtonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
@ -139,6 +144,7 @@
</LinearLayout>
<LinearLayout
android:id="@+id/attachmentStickersButtonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
@ -166,10 +172,10 @@
android:layout_margin="16dp"
android:baselineAligned="false"
android:orientation="horizontal"
android:visibility="gone"
android:weightSum="3">
<LinearLayout
android:id="@+id/attachmentPollButtonContainer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"

View file

@ -3664,4 +3664,31 @@
<item quantity="one">At least %1$s option is required</item>
<item quantity="other">At least %1$s options are required</item>
</plurals>
<plurals name="poll_option_vote_count">
<item quantity="one">%1$d vote</item>
<item quantity="other">%1$d votes</item>
</plurals>
<plurals name="poll_total_vote_count_before_ended_and_voted">
<item quantity="one">Based on %1$d vote</item>
<item quantity="other">Based on %1$d votes</item>
</plurals>
<plurals name="poll_total_vote_count_before_ended_and_not_voted">
<item quantity="zero">No votes cast</item>
<item quantity="one">%1$d vote cast. Vote to the see the results</item>
<item quantity="other">%1$d votes cast. Vote to the see the results</item>
</plurals>
<plurals name="poll_total_vote_count_after_ended">
<item quantity="one">Final result based on %1$d vote</item>
<item quantity="other">Final result based on %1$d votes</item>
</plurals>
<string name="poll_end_action">End poll</string>
<string name="a11y_poll_winner_option">winner option</string>
<string name="end_poll_confirmation_title">End this poll?</string>
<string name="end_poll_confirmation_description">This will stop people from being able to vote and will display the final results of the poll.</string>
<string name="end_poll_confirmation_approve_button">End poll</string>
<string name="labs_enable_polls">Enable Polls</string>
<string name="poll_response_room_list_preview">Vote casted</string>
<string name="poll_end_room_list_preview">Poll ended</string>
<string name="delete_poll_dialog_title">Remove poll</string>
<string name="delete_poll_dialog_content">Are you sure you want to remove this poll? You won\'t be able to recover it once removed.</string>
</resources>

View file

@ -51,4 +51,10 @@
android:summary="@string/labs_use_restricted_join_rule_desc"/>
<!--</im.vector.app.core.preference.VectorPreferenceCategory>-->
<im.vector.app.core.preference.VectorSwitchPreference
android:defaultValue="false"
android:key="SETTINGS_LABS_ENABLE_POLLS"
android:title="@string/labs_enable_polls" />
</androidx.preference.PreferenceScreen>