Merge pull request #5020 from vector-im/feature/ons/edit_polls

Edit Polls and Allow Undisclosed Polls
This commit is contained in:
Benoit Marty 2022-01-25 13:47:49 +01:00 committed by GitHub
commit 5bb06158c7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 486 additions and 72 deletions

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

@ -0,0 +1 @@
Allow editing polls

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

@ -0,0 +1 @@
Support undisclosed polls

View file

@ -22,7 +22,7 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PollCreationInfo(
@Json(name = "question") val question: PollQuestion? = null,
@Json(name = "kind") val kind: String? = "org.matrix.msc3381.poll.disclosed",
@Json(name = "kind") val kind: PollType? = PollType.DISCLOSED,
@Json(name = "max_selections") val maxSelections: Int = 1,
@Json(name = "answers") val answers: List<PollAnswer>? = null
)

View file

@ -0,0 +1,35 @@
/*
* Copyright 2022 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
@JsonClass(generateAdapter = false)
enum class PollType {
/**
* Voters should see results as soon as they have voted.
*/
@Json(name = "org.matrix.msc3381.poll.disclosed")
DISCLOSED,
/**
* Results should be only revealed when the poll is ended.
*/
@Json(name = "org.matrix.msc3381.poll.undisclosed")
UNDISCLOSED
}

View file

@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room.model.relation
import androidx.lifecycle.LiveData
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.api.util.Optional
@ -64,6 +65,18 @@ interface RelationService {
fun undoReaction(targetEventId: String,
reaction: String): Cancelable
/**
* Edit a poll.
* @param pollType indicates open or closed polls
* @param targetEvent The poll event to edit
* @param question The edited question
* @param options The edited options
*/
fun editPoll(targetEvent: TimelineEvent,
pollType: PollType,
question: String,
options: List<String>): Cancelable
/**
* Edit a text message body. Limited to "m.text" contentType
* @param targetEvent The event to edit

View file

@ -20,6 +20,7 @@ import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.events.model.Content
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Cancelable
@ -91,11 +92,12 @@ interface SendService {
/**
* Send a poll to the room.
* @param pollType indicates open or closed polls
* @param question the question
* @param options list of options
* @return a [Cancelable]
*/
fun sendPoll(question: String, options: List<String>): Cancelable
fun sendPoll(pollType: PollType, question: String, options: List<String>): Cancelable
/**
* Method to send a poll response.

View file

@ -133,7 +133,7 @@ fun TimelineEvent.getEditedEventId(): String? {
fun TimelineEvent.getLastMessageContent(): MessageContent? {
return when (root.getClearType()) {
EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>()
EventType.POLL_START -> root.getClearContent().toModel<MessagePollContent>()
EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel<MessagePollContent>()
else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
}
}

View file

@ -34,10 +34,13 @@ 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.MessagePollContent
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.api.session.room.timeline.getLastMessageContent
import org.matrix.android.sdk.internal.SessionManager
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
@ -55,6 +58,7 @@ import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.query.create
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.SessionId
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
@ -63,7 +67,9 @@ import javax.inject.Inject
internal class EventRelationsAggregationProcessor @Inject constructor(
@UserId private val userId: String,
private val stateEventDataSource: StateEventDataSource
private val stateEventDataSource: StateEventDataSource,
@SessionId private val sessionId: String,
private val sessionManager: SessionManager
) : EventInsertLiveProcessor {
private val allowedTypes = listOf(
@ -79,6 +85,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
// EventType.KEY_VERIFICATION_READY,
EventType.KEY_VERIFICATION_KEY,
EventType.ENCRYPTED,
EventType.POLL_START,
EventType.POLL_RESPONSE,
EventType.POLL_END
)
@ -208,6 +215,14 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
}
}
}
EventType.POLL_START -> {
val content: MessagePollContent? = event.content.toModel()
if (content?.relatesTo?.type == RelationType.REPLACE) {
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
// A replace!
handleReplace(realm, event, content, roomId, isLocalEcho)
}
}
EventType.POLL_RESPONSE -> {
event.content.toModel<MessagePollResponseContent>(catchError = true)?.let {
handleResponse(realm, event, it, roomId, isLocalEcho)
@ -274,6 +289,20 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
Timber.v("###REPLACE ignoring event for summary, it's known $eventId")
return
}
ContentMapper
.map(eventAnnotationsSummaryEntity.pollResponseSummary?.aggregatedContent)
?.toModel<PollSummaryContent>()
?.apply {
totalVotes = 0
winnerVoteCount = 0
votes = emptyList()
votesSummary = emptyMap()
}
?.apply {
eventAnnotationsSummaryEntity.pollResponseSummary?.aggregatedContent = ContentMapper.map(toContent())
}
val txId = event.unsignedData?.transactionId
// is it a remote echo?
if (!isLocalEcho && existingSummary.editions.any { it.eventId == txId }) {
@ -315,6 +344,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
val targetEventId = relatedEventId ?: content.relatesTo?.eventId ?: return
val eventTimestamp = event.originServerTs ?: return
val session = sessionManager.getSessionComponent(sessionId)?.session()
val targetPollEvent = session?.getRoom(roomId)?.getTimeLineEvent(targetEventId) ?: return Unit.also {
Timber.v("## POLL target poll event $targetEventId not found in room $roomId")
}
val targetPollContent = targetPollEvent.getLastMessageContent() as? MessagePollContent ?: return Unit.also {
Timber.v("## POLL target poll event $targetEventId content is malformed")
}
// ok, this is a poll response
var existing = EventAnnotationsSummaryEntity.where(realm, roomId, targetEventId).findFirst()
if (existing == null) {
@ -355,6 +394,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
Timber.d("## POLL Ignoring malformed response no option eventId:$eventId content: ${event.content}")
}
// Check if this option is in available options
if (!targetPollContent.pollCreationInfo?.answers?.map { it.id }?.contains(option).orFalse()) {
Timber.v("## POLL $targetEventId doesn't contain option $option")
return
}
val votes = sumModel.votes?.toMutableList() ?: ArrayList()
val existingVoteIndex = votes.indexOfFirst { it.userId == senderId }
if (existingVoteIndex != -1) {

View file

@ -24,6 +24,7 @@ import dagger.assisted.AssistedInject
import org.matrix.android.sdk.api.MatrixCallback
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.model.relation.RelationService
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Cancelable
@ -112,6 +113,13 @@ internal class DefaultRelationService @AssistedInject constructor(
.executeBy(taskExecutor)
}
override fun editPoll(targetEvent: TimelineEvent,
pollType: PollType,
question: String,
options: List<String>): Cancelable {
return eventEditor.editPoll(targetEvent, pollType, question, options)
}
override fun editTextMessage(targetEvent: TimelineEvent,
msgType: String,
newBodyText: CharSequence,

View file

@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.room.relation
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Cancelable
@ -46,13 +47,11 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor:
val editedEvent = eventFactory.createTextEvent(roomId, msgType, newBodyText, newBodyAutoMarkdown).copy(
eventId = targetEvent.eventId
)
updateFailedEchoWithEvent(roomId, targetEvent.eventId, editedEvent)
return eventSenderProcessor.postEvent(editedEvent, cryptoSessionInfoProvider.isRoomEncrypted(roomId))
return sendFailedEvent(targetEvent, editedEvent)
} else if (targetEvent.root.sendState.isSent()) {
val event = eventFactory
.createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyAutoMarkdown, msgType, compatibilityBodyText)
.also { localEchoRepository.createLocalEcho(it) }
return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId))
return sendReplaceEvent(roomId, event)
} else {
// Should we throw?
Timber.w("Can't edit a sending event")
@ -60,6 +59,37 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor:
}
}
fun editPoll(targetEvent: TimelineEvent,
pollType: PollType,
question: String,
options: List<String>): Cancelable {
val roomId = targetEvent.roomId
if (targetEvent.root.sendState.hasFailed()) {
val editedEvent = eventFactory.createPollEvent(roomId, pollType, question, options).copy(
eventId = targetEvent.eventId
)
return sendFailedEvent(targetEvent, editedEvent)
} else if (targetEvent.root.sendState.isSent()) {
val event = eventFactory
.createPollReplaceEvent(roomId, pollType, targetEvent.eventId, question, options)
return sendReplaceEvent(roomId, event)
} else {
Timber.w("Can't edit a sending event")
return NoOpCancellable
}
}
private fun sendFailedEvent(targetEvent: TimelineEvent, editedEvent: Event): Cancelable {
val roomId = targetEvent.roomId
updateFailedEchoWithEvent(roomId, targetEvent.eventId, editedEvent)
return eventSenderProcessor.postEvent(editedEvent, cryptoSessionInfoProvider.isRoomEncrypted(roomId))
}
private fun sendReplaceEvent(roomId: String, editedEvent: Event): Cancelable {
localEchoRepository.createLocalEcho(editedEvent)
return eventSenderProcessor.postEvent(editedEvent, cryptoSessionInfoProvider.isRoomEncrypted(roomId))
}
fun editReply(replyToEdit: TimelineEvent,
originalTimelineEvent: TimelineEvent,
newBodyText: String,

View file

@ -37,6 +37,7 @@ 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.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.send.SendService
import org.matrix.android.sdk.api.session.room.send.SendState
@ -103,8 +104,8 @@ internal class DefaultSendService @AssistedInject constructor(
.let { sendEvent(it) }
}
override fun sendPoll(question: String, options: List<String>): Cancelable {
return localEchoEventFactory.createPollEvent(roomId, question, options)
override fun sendPoll(pollType: PollType, question: String, options: List<String>): Cancelable {
return localEchoEventFactory.createPollEvent(roomId, pollType, question, options)
.also { createLocalEcho(it) }
.let { sendEvent(it) }
}

View file

@ -48,6 +48,7 @@ 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.PollType
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
@ -61,6 +62,7 @@ import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.content.ThumbnailExtractor
import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory
import org.matrix.android.sdk.internal.session.room.send.pills.TextPillsUtils
import java.util.UUID
import javax.inject.Inject
/**
@ -124,6 +126,45 @@ internal class LocalEchoEventFactory @Inject constructor(
))
}
private fun createPollContent(question: String,
options: List<String>,
pollType: PollType): MessagePollContent {
return MessagePollContent(
pollCreationInfo = PollCreationInfo(
question = PollQuestion(
question = question
),
kind = pollType,
answers = options.map { option ->
PollAnswer(
id = UUID.randomUUID().toString(),
answer = option
)
}
)
)
}
fun createPollReplaceEvent(roomId: String,
pollType: PollType,
targetEventId: String,
question: String,
options: List<String>): Event {
val newContent = MessagePollContent(
relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId),
newContent = createPollContent(question, options, pollType).toContent()
)
val localId = LocalEcho.createLocalEchoId()
return Event(
roomId = roomId,
originServerTs = dummyOriginServerTs(),
senderId = userId,
eventId = localId,
type = EventType.POLL_START,
content = newContent.toContent()
)
}
fun createPollReplyEvent(roomId: String,
pollEventId: String,
answerId: String): Event {
@ -149,21 +190,10 @@ internal class LocalEchoEventFactory @Inject constructor(
}
fun createPollEvent(roomId: String,
pollType: PollType,
question: String,
options: List<String>): Event {
val content = MessagePollContent(
pollCreationInfo = PollCreationInfo(
question = PollQuestion(
question = question
),
answers = options.mapIndexed { index, option ->
PollAnswer(
id = "$index-$option",
answer = option
)
}
)
)
val content = createPollContent(question, options, pollType)
val localId = LocalEcho.createLocalEchoId()
return Event(
roomId = roomId,

View file

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

View file

@ -176,6 +176,7 @@ import im.vector.app.features.notifications.NotificationDrawerManager
import im.vector.app.features.notifications.NotificationUtils
import im.vector.app.features.permalink.NavigationInterceptor
import im.vector.app.features.permalink.PermalinkHandler
import im.vector.app.features.poll.create.PollMode
import im.vector.app.features.reactions.EmojiReactionPickerActivity
import im.vector.app.features.roomprofile.RoomProfileActivity
import im.vector.app.features.session.coroutineScope
@ -203,6 +204,7 @@ import org.billcarsonfr.jsonviewer.JSonViewerDialog
import org.commonmark.parser.Parser
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
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.Membership
import org.matrix.android.sdk.api.session.room.model.RoomSummary
@ -1371,7 +1373,6 @@ 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)
}
@ -2020,7 +2021,9 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
}
is EventSharedAction.Edit -> {
if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) {
if (action.eventType == EventType.POLL_START) {
navigator.openCreatePoll(requireContext(), roomDetailArgs.roomId, action.eventId, PollMode.EDIT)
} else if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) {
messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))
} else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit)
@ -2232,7 +2235,7 @@ class RoomDetailFragment @Inject constructor(
AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher)
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher)
AttachmentTypeSelectorView.Type.STICKER -> roomDetailViewModel.handle(RoomDetailAction.SelectStickerAttachment)
AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), roomDetailArgs.roomId)
AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), roomDetailArgs.roomId, null, PollMode.CREATE)
}.exhaustive
}

View file

@ -39,7 +39,7 @@ sealed class EventSharedAction(@StringRes val titleRes: Int,
data class Copy(val content: String) :
EventSharedAction(R.string.action_copy, R.drawable.ic_copy)
data class Edit(val eventId: String) :
data class Edit(val eventId: String, val eventType: String) :
EventSharedAction(R.string.edit, R.drawable.ic_edit)
data class Quote(val eventId: String) :

View file

@ -284,7 +284,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
}
add(EventSharedAction.Remove(eventId))
if (canEdit(timelineEvent, session.myUserId, actionPermissions)) {
add(EventSharedAction.Edit(eventId))
add(EventSharedAction.Edit(eventId, timelineEvent.root.getClearType()))
}
if (canCopy(msgType)) {
// TODO copy images? html? see ClipBoard
@ -329,7 +329,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
}
if (canEdit(timelineEvent, session.myUserId, actionPermissions)) {
add(EventSharedAction.Edit(eventId))
add(EventSharedAction.Edit(eventId, timelineEvent.root.getClearType()))
}
if (canRedact(timelineEvent, actionPermissions)) {
@ -466,14 +466,15 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
}
private fun canEdit(event: TimelineEvent, myUserId: String, actionPermissions: ActionPermissions): Boolean {
// Only event of type EventType.MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false
// Only event of type EventType.MESSAGE and EventType.POLL_START are supported for the moment
if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.POLL_START)) return false
if (!actionPermissions.canSendMessage) return false
// TODO if user is admin or moderator
val messageContent = event.root.getClearContent().toModel<MessageContent>()
return event.root.senderId == myUserId && (
messageContent?.msgType == MessageType.MSGTYPE_TEXT ||
messageContent?.msgType == MessageType.MSGTYPE_EMOTE
messageContent?.msgType == MessageType.MSGTYPE_EMOTE ||
canEditPoll(event)
)
}
@ -516,4 +517,10 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
canRedact(event, actionPermissions) &&
event.annotations?.pollResponseSummary?.closedTime == null
}
private fun canEditPoll(event: TimelineEvent): Boolean {
return event.root.getClearType() == EventType.POLL_START &&
event.annotations?.pollResponseSummary?.closedTime == null &&
event.annotations?.pollResponseSummary?.aggregatedContent?.totalVotes ?: 0 == 0
}
}

View file

@ -89,6 +89,7 @@ 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.PollType
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
@ -186,11 +187,14 @@ class MessageItemFactory @Inject constructor(
val didUserVoted = pollResponseSummary?.myVote?.isNotEmpty().orFalse()
val winnerVoteCount = pollResponseSummary?.winnerVoteCount
val isPollSent = informationData.sendState.isSent()
val isPollUndisclosed = pollContent.pollCreationInfo?.kind == PollType.UNDISCLOSED
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 -> if (it == 0) {
isEnded -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, it, it)
isPollUndisclosed -> ""
didUserVoted -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, it, it)
else -> if (it == 0) {
stringProvider.getString(R.string.poll_no_votes_cast)
} else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_not_voted, it, it)
@ -214,6 +218,9 @@ class MessageItemFactory @Inject constructor(
// 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 (isPollUndisclosed) {
// Poll is closed. Enable option, hide votes and mark the user's selection.
PollOptionViewState.PollUndisclosed(optionId, optionAnswer, isMyVote)
} 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)
@ -224,13 +231,22 @@ class MessageItemFactory @Inject constructor(
)
}
val question = pollContent.pollCreationInfo?.question?.question ?: ""
return PollItem_()
.attributes(attributes)
.eventId(informationData.eventId)
.pollQuestion(pollContent.pollCreationInfo?.question?.question ?: "")
.pollQuestion(
if (informationData.hasBeenEdited) {
annotateWithEdited(question, callback, informationData)
} else {
question
}.toEpoxyCharSequence()
)
.pollSent(isPollSent)
.totalVotesText(totalVotesText)
.optionViewStates(optionViewStates)
.edited(informationData.hasBeenEdited)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
.callback(callback)

View file

@ -119,8 +119,6 @@ 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

@ -24,12 +24,13 @@ 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
import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class PollItem : AbsMessageItem<PollItem.Holder>() {
@EpoxyAttribute
var pollQuestion: String? = null
var pollQuestion: EpoxyCharSequence? = null
@EpoxyAttribute
var callback: TimelineEventController.Callback? = null
@ -43,6 +44,9 @@ abstract class PollItem : AbsMessageItem<PollItem.Holder>() {
@EpoxyAttribute
var totalVotesText: String? = null
@EpoxyAttribute
var edited: Boolean = false
@EpoxyAttribute
lateinit var optionViewStates: List<PollOptionViewState>
@ -52,7 +56,7 @@ abstract class PollItem : AbsMessageItem<PollItem.Holder>() {
renderSendState(holder.view, holder.questionTextView)
holder.questionTextView.text = pollQuestion
holder.questionTextView.text = pollQuestion?.charSequence
holder.totalVotesTextView.text = totalVotesText
while (holder.optionsContainer.childCount < optionViewStates.size) {

View file

@ -23,6 +23,7 @@ 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.exhaustive
import im.vector.app.core.extensions.setAttributeTintedImageResource
import im.vector.app.databinding.ItemPollOptionBinding
@ -43,11 +44,12 @@ class PollOptionView @JvmOverloads constructor(
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)
}
is PollOptionViewState.PollSending -> renderPollSending()
is PollOptionViewState.PollEnded -> renderPollEnded(state)
is PollOptionViewState.PollReady -> renderPollReady()
is PollOptionViewState.PollVoted -> renderPollVoted(state)
is PollOptionViewState.PollUndisclosed -> renderPollUndisclosed(state)
}.exhaustive
}
private fun renderPollSending() {
@ -78,6 +80,12 @@ class PollOptionView @JvmOverloads constructor(
renderVoteSelection(state.isSelected)
}
private fun renderPollUndisclosed(state: PollOptionViewState.PollUndisclosed) {
views.optionCheckImageView.isVisible = true
views.optionWinnerImageView.isVisible = false
renderVoteSelection(state.isSelected)
}
private fun showVotes(voteCount: Int, votePercentage: Double) {
views.optionVoteCountTextView.apply {
isVisible = true

View file

@ -51,4 +51,12 @@ sealed class PollOptionViewState(open val optionId: String,
val votePercentage: Double,
val isWinner: Boolean
) : PollOptionViewState(optionId, optionAnswer)
/**
* Represent a poll that is undisclosed, votes will be hidden until the poll is ended.
*/
data class PollUndisclosed(override val optionId: String,
override val optionAnswer: String,
val isSelected: Boolean
) : PollOptionViewState(optionId, optionAnswer)
}

View file

@ -70,6 +70,7 @@ import im.vector.app.features.pin.PinArgs
import im.vector.app.features.pin.PinMode
import im.vector.app.features.poll.create.CreatePollActivity
import im.vector.app.features.poll.create.CreatePollArgs
import im.vector.app.features.poll.create.PollMode
import im.vector.app.features.roomdirectory.RoomDirectoryActivity
import im.vector.app.features.roomdirectory.RoomDirectoryData
import im.vector.app.features.roomdirectory.createroom.CreateRoomActivity
@ -524,10 +525,10 @@ class DefaultNavigator @Inject constructor(
context.startActivity(intent)
}
override fun openCreatePoll(context: Context, roomId: String) {
override fun openCreatePoll(context: Context, roomId: String, editedEventId: String?, mode: PollMode) {
val intent = CreatePollActivity.getIntent(
context,
CreatePollArgs(roomId = roomId)
CreatePollArgs(roomId = roomId, editedEventId = editedEventId, mode = mode)
)
context.startActivity(intent)
}

View file

@ -28,6 +28,7 @@ import im.vector.app.features.displayname.getBestName
import im.vector.app.features.login.LoginConfig
import im.vector.app.features.media.AttachmentData
import im.vector.app.features.pin.PinMode
import im.vector.app.features.poll.create.PollMode
import im.vector.app.features.roomdirectory.RoomDirectoryData
import im.vector.app.features.roomdirectory.roompreview.RoomPreviewData
import im.vector.app.features.settings.VectorSettingsActivity
@ -148,5 +149,5 @@ interface Navigator {
fun openCallTransfer(context: Context, callId: String)
fun openCreatePoll(context: Context, roomId: String)
fun openCreatePoll(context: Context, roomId: String, editedEventId: String?, mode: PollMode)
}

View file

@ -17,11 +17,13 @@
package im.vector.app.features.poll.create
import im.vector.app.core.platform.VectorViewModelAction
import org.matrix.android.sdk.api.session.room.model.message.PollType
sealed class CreatePollAction : VectorViewModelAction {
data class OnQuestionChanged(val question: String) : CreatePollAction()
data class OnOptionChanged(val index: Int, val option: String) : CreatePollAction()
data class OnDeleteOption(val index: Int) : CreatePollAction()
data class OnPollTypeChanged(val pollType: PollType) : CreatePollAction()
object OnAddOption : CreatePollAction()
object OnCreatePoll : CreatePollAction()
}

View file

@ -28,6 +28,7 @@ import im.vector.app.core.ui.list.genericItem
import im.vector.app.features.form.formEditTextItem
import im.vector.app.features.form.formEditTextWithDeleteItem
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import org.matrix.android.sdk.api.session.room.model.message.PollType
import javax.inject.Inject
class CreatePollController @Inject constructor(
@ -47,6 +48,26 @@ class CreatePollController @Inject constructor(
val currentState = state ?: return
val host = this
genericItem {
id("poll_type_title")
style(ItemStyle.BIG_TEXT)
title(host.stringProvider.getString(R.string.poll_type_title).toEpoxyCharSequence())
}
pollTypeSelectionItem {
id("poll_type_selection")
pollType(currentState.pollType)
pollTypeChangedListener { _, id ->
host.callback?.onPollTypeChanged(
if (id == R.id.openPollTypeRadioButton) {
PollType.DISCLOSED
} else {
PollType.UNDISCLOSED
}
)
}
}
genericItem {
id("question_title")
style(ItemStyle.BIG_TEXT)
@ -110,5 +131,6 @@ class CreatePollController @Inject constructor(
fun onOptionChanged(index: Int, option: String)
fun onDeleteOption(index: Int)
fun onAddOption()
fun onPollTypeChanged(type: PollType)
}
}

View file

@ -23,18 +23,23 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.args
import com.airbnb.mvrx.withState
import im.vector.app.R
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentCreatePollBinding
import im.vector.app.features.poll.create.CreatePollViewModel.Companion.MAX_OPTIONS_COUNT
import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.session.room.model.message.PollType
import javax.inject.Inject
@Parcelize
data class CreatePollArgs(
val roomId: String,
val editedEventId: String?,
val mode: PollMode
) : Parcelable
class CreatePollFragment @Inject constructor(
@ -42,6 +47,7 @@ class CreatePollFragment @Inject constructor(
) : VectorBaseFragment<FragmentCreatePollBinding>(), CreatePollController.Callback {
private val viewModel: CreatePollViewModel by activityViewModel()
private val args: CreatePollArgs by args()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentCreatePollBinding {
return FragmentCreatePollBinding.inflate(inflater, container, false)
@ -53,9 +59,20 @@ class CreatePollFragment @Inject constructor(
setupToolbar(views.createPollToolbar)
.allowBack(useCross = true)
when (args.mode) {
PollMode.CREATE -> {
views.createPollToolbar.title = getString(R.string.create_poll_title)
views.createPollButton.text = getString(R.string.create_poll_title)
}
PollMode.EDIT -> {
views.createPollToolbar.title = getString(R.string.edit_poll_title)
views.createPollButton.text = getString(R.string.edit_poll_title)
}
}.exhaustive
views.createPollRecyclerView.configureWith(controller, disableItemAnimation = true)
// workaround for https://github.com/vector-im/element-android/issues/4735
views.createPollRecyclerView.setItemViewCacheSize(MAX_OPTIONS_COUNT + 4)
views.createPollRecyclerView.setItemViewCacheSize(MAX_OPTIONS_COUNT + 6)
controller.callback = this
views.createPollButton.debouncedClicks {
@ -101,6 +118,10 @@ class CreatePollFragment @Inject constructor(
}
}
override fun onPollTypeChanged(type: PollType) {
viewModel.handle(CreatePollAction.OnPollTypeChanged(type))
}
private fun handleSuccess() {
requireActivity().finish()
}

View file

@ -24,6 +24,9 @@ import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
class CreatePollViewModel @AssistedInject constructor(
@Assisted private val initialState: CreatePollViewState,
@ -45,6 +48,9 @@ class CreatePollViewModel @AssistedInject constructor(
init {
observeState()
initialState.editedEventId?.let {
initializeEditedPoll(it)
}
}
private fun observeState() {
@ -61,6 +67,23 @@ class CreatePollViewModel @AssistedInject constructor(
}
}
private fun initializeEditedPoll(eventId: String) {
val event = room.getTimeLineEvent(eventId) ?: return
val content = event.getLastMessageContent() as? MessagePollContent ?: return
val pollType = content.pollCreationInfo?.kind ?: PollType.DISCLOSED
val question = content.pollCreationInfo?.question?.question ?: ""
val options = content.pollCreationInfo?.answers?.mapNotNull { it.answer } ?: List(MIN_OPTIONS_COUNT) { "" }
setState {
copy(
question = question,
options = options,
pollType = pollType
)
}
}
override fun handle(action: CreatePollAction) {
when (action) {
CreatePollAction.OnCreatePoll -> handleOnCreatePoll()
@ -68,6 +91,7 @@ class CreatePollViewModel @AssistedInject constructor(
is CreatePollAction.OnDeleteOption -> handleOnDeleteOption(action.index)
is CreatePollAction.OnOptionChanged -> handleOnOptionChanged(action.index, action.option)
is CreatePollAction.OnQuestionChanged -> handleOnQuestionChanged(action.question)
is CreatePollAction.OnPollTypeChanged -> handleOnPollTypeChanged(action.pollType)
}
}
@ -81,12 +105,20 @@ class CreatePollViewModel @AssistedInject constructor(
_viewEvents.post(CreatePollViewEvents.NotEnoughOptionsError(requiredOptionsCount = MIN_OPTIONS_COUNT))
}
else -> {
room.sendPoll(state.question, nonEmptyOptions)
when (state.mode) {
PollMode.CREATE -> room.sendPoll(state.pollType, state.question, nonEmptyOptions)
PollMode.EDIT -> sendEditedPoll(state.editedEventId!!, state.pollType, state.question, nonEmptyOptions)
}
_viewEvents.post(CreatePollViewEvents.Success)
}
}
}
private fun sendEditedPoll(editedEventId: String, pollType: PollType, question: String, options: List<String>) {
val editedEvent = room.getTimeLineEvent(editedEventId) ?: return
room.editPoll(editedEvent, pollType, question, options)
}
private fun handleOnAddOption() {
setState {
val extendedOptions = options + ""
@ -122,6 +154,14 @@ class CreatePollViewModel @AssistedInject constructor(
}
}
private fun handleOnPollTypeChanged(pollType: PollType) {
setState {
copy(
pollType = pollType
)
}
}
private fun canCreatePoll(question: String, options: List<String>): Boolean {
return question.isNotEmpty() &&
options.filter { it.isNotEmpty() }.size >= MIN_OPTIONS_COUNT

View file

@ -17,16 +17,22 @@
package im.vector.app.features.poll.create
import com.airbnb.mvrx.MavericksState
import org.matrix.android.sdk.api.session.room.model.message.PollType
data class CreatePollViewState(
val roomId: String,
val editedEventId: String?,
val mode: PollMode,
val question: String = "",
val options: List<String> = List(CreatePollViewModel.MIN_OPTIONS_COUNT) { "" },
val canCreatePoll: Boolean = false,
val canAddMoreOptions: Boolean = true
val canAddMoreOptions: Boolean = true,
val pollType: PollType = PollType.DISCLOSED
) : MavericksState {
constructor(args: CreatePollArgs) : this(
roomId = args.roomId
roomId = args.roomId,
editedEventId = args.editedEventId,
mode = args.mode
)
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2022 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.poll.create
enum class PollMode {
CREATE,
EDIT
}

View file

@ -0,0 +1,57 @@
/*
* Copyright (c) 2022 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.poll.create
import android.widget.RadioGroup
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import org.matrix.android.sdk.api.session.room.model.message.PollType
@EpoxyModelClass(layout = R.layout.item_poll_type_selection)
abstract class PollTypeSelectionItem : VectorEpoxyModel<PollTypeSelectionItem.Holder>() {
@EpoxyAttribute
var pollType: PollType = PollType.DISCLOSED
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var pollTypeChangedListener: RadioGroup.OnCheckedChangeListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
holder.pollTypeRadioGroup.check(
when (pollType) {
PollType.DISCLOSED -> R.id.openPollTypeRadioButton
PollType.UNDISCLOSED -> R.id.closedPollTypeRadioButton
}
)
holder.pollTypeRadioGroup.setOnCheckedChangeListener(pollTypeChangedListener)
}
override fun unbind(holder: Holder) {
super.unbind(holder)
holder.pollTypeRadioGroup.setOnCheckedChangeListener(null)
}
class Holder : VectorEpoxyHolder() {
val pollTypeRadioGroup by bind<RadioGroup>(R.id.pollTypeRadioGroup)
}
}

View file

@ -196,8 +196,6 @@ 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
@ -991,8 +989,4 @@ 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,40 @@
<?xml version="1.0" encoding="utf-8"?>
<RadioGroup xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/pollTypeRadioGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:orientation="vertical"
android:paddingBottom="@dimen/layout_vertical_margin">
<RadioButton
android:id="@+id/openPollTypeRadioButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="0dp"
android:text="@string/open_poll_option_title" />
<TextView
style="@style/TextAppearance.Vector.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:text="@string/open_poll_option_description" />
<RadioButton
android:id="@+id/closedPollTypeRadioButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:minHeight="0dp"
android:text="@string/closed_poll_option_title" />
<TextView
style="@style/TextAppearance.Vector.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:text="@string/closed_poll_option_description" />
</RadioGroup>

View file

@ -3707,11 +3707,19 @@
<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>
<!-- TODO. Remove -->
<string name="labs_enable_polls">Enable Polls</string>
<string name="poll_response_room_list_preview">Vote casted</string>
<string name="poll_response_room_list_preview">Vote cast</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>
<string name="edit_poll_title">Edit poll</string>
<string name="edit_poll_button">EDIT POLL</string>
<string name="poll_type_title">Poll type</string>
<string name="open_poll_option_title">Open poll</string>
<string name="open_poll_option_description">Voters see results as soon as they have voted</string>
<string name="closed_poll_option_title">Closed poll</string>
<string name="closed_poll_option_description">Results are only revealed when you end the poll</string>
<string name="tooltip_attachment_photo">Open camera</string>
<string name="tooltip_attachment_gallery">Send images and videos</string>

View file

@ -53,14 +53,8 @@
<im.vector.app.core.preference.VectorSwitchPreference
android:defaultValue="false"
android:key="SETTINGS_LABS_ENABLE_POLLS"
android:title="@string/labs_enable_polls" />
<im.vector.app.core.preference.VectorSwitchPreference
android:defaultValue="false"
android:key="SETTINGS_LABS_AUTO_REPORT_UISI"
android:title="@string/labs_auto_report_uisi"
android:summary="@string/labs_auto_report_uisi_desc"/>
android:key="SETTINGS_LABS_AUTO_REPORT_UISI"
android:summary="@string/labs_auto_report_uisi_desc"
android:title="@string/labs_auto_report_uisi" />
</androidx.preference.PreferenceScreen>