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) @JsonClass(generateAdapter = true)
data class PollCreationInfo( data class PollCreationInfo(
@Json(name = "question") val question: PollQuestion? = null, @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 = "max_selections") val maxSelections: Int = 1,
@Json(name = "answers") val answers: List<PollAnswer>? = null @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 androidx.lifecycle.LiveData
import org.matrix.android.sdk.api.session.events.model.Event 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.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.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.api.util.Optional import org.matrix.android.sdk.api.util.Optional
@ -64,6 +65,18 @@ interface RelationService {
fun undoReaction(targetEventId: String, fun undoReaction(targetEventId: String,
reaction: String): Cancelable 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 * Edit a text message body. Limited to "m.text" contentType
* @param targetEvent The event to edit * @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.Content
import org.matrix.android.sdk.api.session.events.model.Event 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.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.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.Cancelable
@ -91,11 +92,12 @@ interface SendService {
/** /**
* Send a poll to the room. * Send a poll to the room.
* @param pollType indicates open or closed polls
* @param question the question * @param question the question
* @param options list of options * @param options list of options
* @return a [Cancelable] * @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. * Method to send a poll response.

View file

@ -133,7 +133,7 @@ fun TimelineEvent.getEditedEventId(): String? {
fun TimelineEvent.getLastMessageContent(): MessageContent? { fun TimelineEvent.getLastMessageContent(): MessageContent? {
return when (root.getClearType()) { return when (root.getClearType()) {
EventType.STICKER -> root.getClearContent().toModel<MessageStickerContent>() 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() 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.VoteSummary
import org.matrix.android.sdk.api.session.room.model.message.MessageContent 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.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.MessagePollResponseContent
import org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent 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.model.relation.ReactionContent
import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper 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.model.event.EncryptedEventContent
import org.matrix.android.sdk.internal.crypto.verification.toState import org.matrix.android.sdk.internal.crypto.verification.toState
import org.matrix.android.sdk.internal.database.mapper.ContentMapper 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.create
import org.matrix.android.sdk.internal.database.query.getOrCreate import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.database.query.where 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.di.UserId
import org.matrix.android.sdk.internal.session.EventInsertLiveProcessor import org.matrix.android.sdk.internal.session.EventInsertLiveProcessor
import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource
@ -63,7 +67,9 @@ import javax.inject.Inject
internal class EventRelationsAggregationProcessor @Inject constructor( internal class EventRelationsAggregationProcessor @Inject constructor(
@UserId private val userId: String, @UserId private val userId: String,
private val stateEventDataSource: StateEventDataSource private val stateEventDataSource: StateEventDataSource,
@SessionId private val sessionId: String,
private val sessionManager: SessionManager
) : EventInsertLiveProcessor { ) : EventInsertLiveProcessor {
private val allowedTypes = listOf( private val allowedTypes = listOf(
@ -79,6 +85,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
// EventType.KEY_VERIFICATION_READY, // EventType.KEY_VERIFICATION_READY,
EventType.KEY_VERIFICATION_KEY, EventType.KEY_VERIFICATION_KEY,
EventType.ENCRYPTED, EventType.ENCRYPTED,
EventType.POLL_START,
EventType.POLL_RESPONSE, EventType.POLL_RESPONSE,
EventType.POLL_END 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 -> { EventType.POLL_RESPONSE -> {
event.content.toModel<MessagePollResponseContent>(catchError = true)?.let { event.content.toModel<MessagePollResponseContent>(catchError = true)?.let {
handleResponse(realm, event, it, roomId, isLocalEcho) 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") Timber.v("###REPLACE ignoring event for summary, it's known $eventId")
return 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 val txId = event.unsignedData?.transactionId
// is it a remote echo? // is it a remote echo?
if (!isLocalEcho && existingSummary.editions.any { it.eventId == txId }) { 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 targetEventId = relatedEventId ?: content.relatesTo?.eventId ?: return
val eventTimestamp = event.originServerTs ?: 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 // ok, this is a poll response
var existing = EventAnnotationsSummaryEntity.where(realm, roomId, targetEventId).findFirst() var existing = EventAnnotationsSummaryEntity.where(realm, roomId, targetEventId).findFirst()
if (existing == null) { 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}") 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 votes = sumModel.votes?.toMutableList() ?: ArrayList()
val existingVoteIndex = votes.indexOfFirst { it.userId == senderId } val existingVoteIndex = votes.indexOfFirst { it.userId == senderId }
if (existingVoteIndex != -1) { 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.MatrixCallback
import org.matrix.android.sdk.api.session.events.model.Event 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.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.model.relation.RelationService
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent 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.Cancelable
@ -112,6 +113,13 @@ internal class DefaultRelationService @AssistedInject constructor(
.executeBy(taskExecutor) .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, override fun editTextMessage(targetEvent: TimelineEvent,
msgType: String, msgType: String,
newBodyText: CharSequence, 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.events.model.Event
import org.matrix.android.sdk.api.session.room.model.message.MessageType 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.send.SendState
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent 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.Cancelable
@ -46,13 +47,11 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor:
val editedEvent = eventFactory.createTextEvent(roomId, msgType, newBodyText, newBodyAutoMarkdown).copy( val editedEvent = eventFactory.createTextEvent(roomId, msgType, newBodyText, newBodyAutoMarkdown).copy(
eventId = targetEvent.eventId eventId = targetEvent.eventId
) )
updateFailedEchoWithEvent(roomId, targetEvent.eventId, editedEvent) return sendFailedEvent(targetEvent, editedEvent)
return eventSenderProcessor.postEvent(editedEvent, cryptoSessionInfoProvider.isRoomEncrypted(roomId))
} else if (targetEvent.root.sendState.isSent()) { } else if (targetEvent.root.sendState.isSent()) {
val event = eventFactory val event = eventFactory
.createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyAutoMarkdown, msgType, compatibilityBodyText) .createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyAutoMarkdown, msgType, compatibilityBodyText)
.also { localEchoRepository.createLocalEcho(it) } return sendReplaceEvent(roomId, event)
return eventSenderProcessor.postEvent(event, cryptoSessionInfoProvider.isRoomEncrypted(roomId))
} else { } else {
// Should we throw? // Should we throw?
Timber.w("Can't edit a sending event") 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, fun editReply(replyToEdit: TimelineEvent,
originalTimelineEvent: TimelineEvent, originalTimelineEvent: TimelineEvent,
newBodyText: String, 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.MessageImageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent 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.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.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.send.SendService
import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.send.SendState
@ -103,8 +104,8 @@ internal class DefaultSendService @AssistedInject constructor(
.let { sendEvent(it) } .let { sendEvent(it) }
} }
override fun sendPoll(question: String, options: List<String>): Cancelable { override fun sendPoll(pollType: PollType, question: String, options: List<String>): Cancelable {
return localEchoEventFactory.createPollEvent(roomId, question, options) return localEchoEventFactory.createPollEvent(roomId, pollType, question, options)
.also { createLocalEcho(it) } .also { createLocalEcho(it) }
.let { sendEvent(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.PollCreationInfo
import org.matrix.android.sdk.api.session.room.model.message.PollQuestion 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.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.ThumbnailInfo
import org.matrix.android.sdk.api.session.room.model.message.VideoInfo import org.matrix.android.sdk.api.session.room.model.message.VideoInfo
import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent 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.content.ThumbnailExtractor
import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory
import org.matrix.android.sdk.internal.session.room.send.pills.TextPillsUtils import org.matrix.android.sdk.internal.session.room.send.pills.TextPillsUtils
import java.util.UUID
import javax.inject.Inject 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, fun createPollReplyEvent(roomId: String,
pollEventId: String, pollEventId: String,
answerId: String): Event { answerId: String): Event {
@ -149,21 +190,10 @@ internal class LocalEchoEventFactory @Inject constructor(
} }
fun createPollEvent(roomId: String, fun createPollEvent(roomId: String,
pollType: PollType,
question: String, question: String,
options: List<String>): Event { options: List<String>): Event {
val content = MessagePollContent( val content = createPollContent(question, options, pollType)
pollCreationInfo = PollCreationInfo(
question = PollQuestion(
question = question
),
answers = options.mapIndexed { index, option ->
PollAnswer(
id = "$index-$option",
answer = option
)
}
)
)
val localId = LocalEcho.createLocalEchoId() val localId = LocalEcho.createLocalEchoId()
return Event( return Event(
roomId = roomId, roomId = roomId,

View file

@ -48,8 +48,4 @@ class UserPreferencesProvider @Inject constructor(private val vectorPreferences:
fun shouldShowAvatarDisplayNameChanges(): Boolean { fun shouldShowAvatarDisplayNameChanges(): Boolean {
return vectorPreferences.showAvatarDisplayNameChangeMessages() 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.notifications.NotificationUtils
import im.vector.app.features.permalink.NavigationInterceptor import im.vector.app.features.permalink.NavigationInterceptor
import im.vector.app.features.permalink.PermalinkHandler 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.reactions.EmojiReactionPickerActivity
import im.vector.app.features.roomprofile.RoomProfileActivity import im.vector.app.features.roomprofile.RoomProfileActivity
import im.vector.app.features.session.coroutineScope import im.vector.app.features.session.coroutineScope
@ -203,6 +204,7 @@ import org.billcarsonfr.jsonviewer.JSonViewerDialog
import org.commonmark.parser.Parser import org.commonmark.parser.Parser
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.content.ContentAttachmentData 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.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.Membership
import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary
@ -1371,7 +1373,6 @@ class RoomDetailFragment @Inject constructor(
override fun onAddAttachment() { override fun onAddAttachment() {
if (!::attachmentTypeSelector.isInitialized) { if (!::attachmentTypeSelector.isInitialized) {
attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@RoomDetailFragment) attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@RoomDetailFragment)
attachmentTypeSelector.setAttachmentVisibility(AttachmentTypeSelectorView.Type.POLL, vectorPreferences.labsEnablePolls())
} }
attachmentTypeSelector.show(views.composerLayout.views.attachmentButton) attachmentTypeSelector.show(views.composerLayout.views.attachmentButton)
} }
@ -2020,7 +2021,9 @@ class RoomDetailFragment @Inject constructor(
roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add)) roomDetailViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add))
} }
is EventSharedAction.Edit -> { 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())) messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId, views.composerLayout.text.toString()))
} else { } else {
requireActivity().toast(R.string.error_voice_message_cannot_reply_or_edit) 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.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher)
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher)
AttachmentTypeSelectorView.Type.STICKER -> roomDetailViewModel.handle(RoomDetailAction.SelectStickerAttachment) 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 }.exhaustive
} }

View file

@ -39,7 +39,7 @@ sealed class EventSharedAction(@StringRes val titleRes: Int,
data class Copy(val content: String) : data class Copy(val content: String) :
EventSharedAction(R.string.action_copy, R.drawable.ic_copy) 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) EventSharedAction(R.string.edit, R.drawable.ic_edit)
data class Quote(val eventId: String) : data class Quote(val eventId: String) :

View file

@ -284,7 +284,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
} }
add(EventSharedAction.Remove(eventId)) add(EventSharedAction.Remove(eventId))
if (canEdit(timelineEvent, session.myUserId, actionPermissions)) { if (canEdit(timelineEvent, session.myUserId, actionPermissions)) {
add(EventSharedAction.Edit(eventId)) add(EventSharedAction.Edit(eventId, timelineEvent.root.getClearType()))
} }
if (canCopy(msgType)) { if (canCopy(msgType)) {
// TODO copy images? html? see ClipBoard // TODO copy images? html? see ClipBoard
@ -329,7 +329,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
} }
if (canEdit(timelineEvent, session.myUserId, actionPermissions)) { if (canEdit(timelineEvent, session.myUserId, actionPermissions)) {
add(EventSharedAction.Edit(eventId)) add(EventSharedAction.Edit(eventId, timelineEvent.root.getClearType()))
} }
if (canRedact(timelineEvent, actionPermissions)) { if (canRedact(timelineEvent, actionPermissions)) {
@ -466,14 +466,15 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
} }
private fun canEdit(event: TimelineEvent, myUserId: String, actionPermissions: ActionPermissions): Boolean { private fun canEdit(event: TimelineEvent, myUserId: String, actionPermissions: ActionPermissions): Boolean {
// Only event of type EventType.MESSAGE are supported for the moment // Only event of type EventType.MESSAGE and EventType.POLL_START are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.POLL_START)) return false
if (!actionPermissions.canSendMessage) return false if (!actionPermissions.canSendMessage) return false
// TODO if user is admin or moderator // TODO if user is admin or moderator
val messageContent = event.root.getClearContent().toModel<MessageContent>() val messageContent = event.root.getClearContent().toModel<MessageContent>()
return event.root.senderId == myUserId && ( return event.root.senderId == myUserId && (
messageContent?.msgType == MessageType.MSGTYPE_TEXT || 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) && canRedact(event, actionPermissions) &&
event.annotations?.pollResponseSummary?.closedTime == null 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.MessageType
import org.matrix.android.sdk.api.session.room.model.message.MessageVerificationRequestContent 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.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.getFileName
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl 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 didUserVoted = pollResponseSummary?.myVote?.isNotEmpty().orFalse()
val winnerVoteCount = pollResponseSummary?.winnerVoteCount val winnerVoteCount = pollResponseSummary?.winnerVoteCount
val isPollSent = informationData.sendState.isSent() val isPollSent = informationData.sendState.isSent()
val isPollUndisclosed = pollContent.pollCreationInfo?.kind == PollType.UNDISCLOSED
val totalVotesText = (pollResponseSummary?.totalVotes ?: 0).let { val totalVotesText = (pollResponseSummary?.totalVotes ?: 0).let {
when { when {
isEnded -> stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, it, it) 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) isPollUndisclosed -> ""
else -> if (it == 0) { 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) stringProvider.getString(R.string.poll_no_votes_cast)
} else { } else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_not_voted, it, it) 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. // Poll is ended. Disable option, show votes and mark the winner.
val isWinner = winnerVoteCount != 0 && voteCount == winnerVoteCount val isWinner = winnerVoteCount != 0 && voteCount == winnerVoteCount
PollOptionViewState.PollEnded(optionId, optionAnswer, voteCount, votePercentage, isWinner) 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) { } else if (didUserVoted) {
// User voted to the poll, but poll is not ended. Enable option, show votes and mark the user's selection. // 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) PollOptionViewState.PollVoted(optionId, optionAnswer, voteCount, votePercentage, isMyVote)
@ -224,13 +231,22 @@ class MessageItemFactory @Inject constructor(
) )
} }
val question = pollContent.pollCreationInfo?.question?.question ?: ""
return PollItem_() return PollItem_()
.attributes(attributes) .attributes(attributes)
.eventId(informationData.eventId) .eventId(informationData.eventId)
.pollQuestion(pollContent.pollCreationInfo?.question?.question ?: "") .pollQuestion(
if (informationData.hasBeenEdited) {
annotateWithEdited(question, callback, informationData)
} else {
question
}.toEpoxyCharSequence()
)
.pollSent(isPollSent) .pollSent(isPollSent)
.totalVotesText(totalVotesText) .totalVotesText(totalVotesText)
.optionViewStates(optionViewStates) .optionViewStates(optionViewStates)
.edited(informationData.hasBeenEdited)
.highlighted(highlight) .highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.callback(callback) .callback(callback)

View file

@ -119,8 +119,6 @@ class TimelineEventVisibilityHelper @Inject constructor(private val userPreferen
val diff = computeMembershipDiff() val diff = computeMembershipDiff()
if ((diff.isJoin || diff.isPart) && !userPreferencesProvider.shouldShowJoinLeaves()) return true if ((diff.isJoin || diff.isPart) && !userPreferencesProvider.shouldShowJoinLeaves()) return true
if ((diff.isAvatarChange || diff.isDisplaynameChange) && !userPreferencesProvider.shouldShowAvatarDisplayNameChanges()) return true if ((diff.isAvatarChange || diff.isDisplaynameChange) && !userPreferencesProvider.shouldShowAvatarDisplayNameChanges()) return true
} else if (root.getClearType() == EventType.POLL_START && !userPreferencesProvider.shouldShowPolls()) {
return true
} }
return false return false
} }

View file

@ -24,12 +24,13 @@ import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.home.room.detail.RoomDetailAction import im.vector.app.features.home.room.detail.RoomDetailAction
import im.vector.app.features.home.room.detail.timeline.TimelineEventController 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) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class PollItem : AbsMessageItem<PollItem.Holder>() { abstract class PollItem : AbsMessageItem<PollItem.Holder>() {
@EpoxyAttribute @EpoxyAttribute
var pollQuestion: String? = null var pollQuestion: EpoxyCharSequence? = null
@EpoxyAttribute @EpoxyAttribute
var callback: TimelineEventController.Callback? = null var callback: TimelineEventController.Callback? = null
@ -43,6 +44,9 @@ abstract class PollItem : AbsMessageItem<PollItem.Holder>() {
@EpoxyAttribute @EpoxyAttribute
var totalVotesText: String? = null var totalVotesText: String? = null
@EpoxyAttribute
var edited: Boolean = false
@EpoxyAttribute @EpoxyAttribute
lateinit var optionViewStates: List<PollOptionViewState> lateinit var optionViewStates: List<PollOptionViewState>
@ -52,7 +56,7 @@ abstract class PollItem : AbsMessageItem<PollItem.Holder>() {
renderSendState(holder.view, holder.questionTextView) renderSendState(holder.view, holder.questionTextView)
holder.questionTextView.text = pollQuestion holder.questionTextView.text = pollQuestion?.charSequence
holder.totalVotesTextView.text = totalVotesText holder.totalVotesTextView.text = totalVotesText
while (holder.optionsContainer.childCount < optionViewStates.size) { 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.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.extensions.setAttributeTintedImageResource import im.vector.app.core.extensions.setAttributeTintedImageResource
import im.vector.app.databinding.ItemPollOptionBinding import im.vector.app.databinding.ItemPollOptionBinding
@ -43,11 +44,12 @@ class PollOptionView @JvmOverloads constructor(
views.optionNameTextView.text = state.optionAnswer views.optionNameTextView.text = state.optionAnswer
when (state) { when (state) {
is PollOptionViewState.PollSending -> renderPollSending() is PollOptionViewState.PollSending -> renderPollSending()
is PollOptionViewState.PollEnded -> renderPollEnded(state) is PollOptionViewState.PollEnded -> renderPollEnded(state)
is PollOptionViewState.PollReady -> renderPollReady() is PollOptionViewState.PollReady -> renderPollReady()
is PollOptionViewState.PollVoted -> renderPollVoted(state) is PollOptionViewState.PollVoted -> renderPollVoted(state)
} is PollOptionViewState.PollUndisclosed -> renderPollUndisclosed(state)
}.exhaustive
} }
private fun renderPollSending() { private fun renderPollSending() {
@ -78,6 +80,12 @@ class PollOptionView @JvmOverloads constructor(
renderVoteSelection(state.isSelected) 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) { private fun showVotes(voteCount: Int, votePercentage: Double) {
views.optionVoteCountTextView.apply { views.optionVoteCountTextView.apply {
isVisible = true isVisible = true

View file

@ -51,4 +51,12 @@ sealed class PollOptionViewState(open val optionId: String,
val votePercentage: Double, val votePercentage: Double,
val isWinner: Boolean val isWinner: Boolean
) : PollOptionViewState(optionId, optionAnswer) ) : 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.pin.PinMode
import im.vector.app.features.poll.create.CreatePollActivity import im.vector.app.features.poll.create.CreatePollActivity
import im.vector.app.features.poll.create.CreatePollArgs 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.RoomDirectoryActivity
import im.vector.app.features.roomdirectory.RoomDirectoryData import im.vector.app.features.roomdirectory.RoomDirectoryData
import im.vector.app.features.roomdirectory.createroom.CreateRoomActivity import im.vector.app.features.roomdirectory.createroom.CreateRoomActivity
@ -524,10 +525,10 @@ class DefaultNavigator @Inject constructor(
context.startActivity(intent) 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( val intent = CreatePollActivity.getIntent(
context, context,
CreatePollArgs(roomId = roomId) CreatePollArgs(roomId = roomId, editedEventId = editedEventId, mode = mode)
) )
context.startActivity(intent) 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.login.LoginConfig
import im.vector.app.features.media.AttachmentData import im.vector.app.features.media.AttachmentData
import im.vector.app.features.pin.PinMode 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.RoomDirectoryData
import im.vector.app.features.roomdirectory.roompreview.RoomPreviewData import im.vector.app.features.roomdirectory.roompreview.RoomPreviewData
import im.vector.app.features.settings.VectorSettingsActivity import im.vector.app.features.settings.VectorSettingsActivity
@ -148,5 +149,5 @@ interface Navigator {
fun openCallTransfer(context: Context, callId: String) 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 package im.vector.app.features.poll.create
import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.core.platform.VectorViewModelAction
import org.matrix.android.sdk.api.session.room.model.message.PollType
sealed class CreatePollAction : VectorViewModelAction { sealed class CreatePollAction : VectorViewModelAction {
data class OnQuestionChanged(val question: String) : CreatePollAction() data class OnQuestionChanged(val question: String) : CreatePollAction()
data class OnOptionChanged(val index: Int, val option: String) : CreatePollAction() data class OnOptionChanged(val index: Int, val option: String) : CreatePollAction()
data class OnDeleteOption(val index: Int) : CreatePollAction() data class OnDeleteOption(val index: Int) : CreatePollAction()
data class OnPollTypeChanged(val pollType: PollType) : CreatePollAction()
object OnAddOption : CreatePollAction() object OnAddOption : CreatePollAction()
object OnCreatePoll : 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.formEditTextItem
import im.vector.app.features.form.formEditTextWithDeleteItem import im.vector.app.features.form.formEditTextWithDeleteItem
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
import org.matrix.android.sdk.api.session.room.model.message.PollType
import javax.inject.Inject import javax.inject.Inject
class CreatePollController @Inject constructor( class CreatePollController @Inject constructor(
@ -47,6 +48,26 @@ class CreatePollController @Inject constructor(
val currentState = state ?: return val currentState = state ?: return
val host = this 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 { genericItem {
id("question_title") id("question_title")
style(ItemStyle.BIG_TEXT) style(ItemStyle.BIG_TEXT)
@ -110,5 +131,6 @@ class CreatePollController @Inject constructor(
fun onOptionChanged(index: Int, option: String) fun onOptionChanged(index: Int, option: String)
fun onDeleteOption(index: Int) fun onDeleteOption(index: Int)
fun onAddOption() fun onAddOption()
fun onPollTypeChanged(type: PollType)
} }
} }

View file

@ -23,18 +23,23 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.mvrx.activityViewModel import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.args
import com.airbnb.mvrx.withState import com.airbnb.mvrx.withState
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.extensions.configureWith 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.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentCreatePollBinding import im.vector.app.databinding.FragmentCreatePollBinding
import im.vector.app.features.poll.create.CreatePollViewModel.Companion.MAX_OPTIONS_COUNT import im.vector.app.features.poll.create.CreatePollViewModel.Companion.MAX_OPTIONS_COUNT
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.matrix.android.sdk.api.session.room.model.message.PollType
import javax.inject.Inject import javax.inject.Inject
@Parcelize @Parcelize
data class CreatePollArgs( data class CreatePollArgs(
val roomId: String, val roomId: String,
val editedEventId: String?,
val mode: PollMode
) : Parcelable ) : Parcelable
class CreatePollFragment @Inject constructor( class CreatePollFragment @Inject constructor(
@ -42,6 +47,7 @@ class CreatePollFragment @Inject constructor(
) : VectorBaseFragment<FragmentCreatePollBinding>(), CreatePollController.Callback { ) : VectorBaseFragment<FragmentCreatePollBinding>(), CreatePollController.Callback {
private val viewModel: CreatePollViewModel by activityViewModel() private val viewModel: CreatePollViewModel by activityViewModel()
private val args: CreatePollArgs by args()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentCreatePollBinding { override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentCreatePollBinding {
return FragmentCreatePollBinding.inflate(inflater, container, false) return FragmentCreatePollBinding.inflate(inflater, container, false)
@ -53,9 +59,20 @@ class CreatePollFragment @Inject constructor(
setupToolbar(views.createPollToolbar) setupToolbar(views.createPollToolbar)
.allowBack(useCross = true) .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) views.createPollRecyclerView.configureWith(controller, disableItemAnimation = true)
// workaround for https://github.com/vector-im/element-android/issues/4735 // 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 controller.callback = this
views.createPollButton.debouncedClicks { views.createPollButton.debouncedClicks {
@ -101,6 +118,10 @@ class CreatePollFragment @Inject constructor(
} }
} }
override fun onPollTypeChanged(type: PollType) {
viewModel.handle(CreatePollAction.OnPollTypeChanged(type))
}
private fun handleSuccess() { private fun handleSuccess() {
requireActivity().finish() 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.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import org.matrix.android.sdk.api.session.Session 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( class CreatePollViewModel @AssistedInject constructor(
@Assisted private val initialState: CreatePollViewState, @Assisted private val initialState: CreatePollViewState,
@ -45,6 +48,9 @@ class CreatePollViewModel @AssistedInject constructor(
init { init {
observeState() observeState()
initialState.editedEventId?.let {
initializeEditedPoll(it)
}
} }
private fun observeState() { 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) { override fun handle(action: CreatePollAction) {
when (action) { when (action) {
CreatePollAction.OnCreatePoll -> handleOnCreatePoll() CreatePollAction.OnCreatePoll -> handleOnCreatePoll()
@ -68,6 +91,7 @@ class CreatePollViewModel @AssistedInject constructor(
is CreatePollAction.OnDeleteOption -> handleOnDeleteOption(action.index) is CreatePollAction.OnDeleteOption -> handleOnDeleteOption(action.index)
is CreatePollAction.OnOptionChanged -> handleOnOptionChanged(action.index, action.option) is CreatePollAction.OnOptionChanged -> handleOnOptionChanged(action.index, action.option)
is CreatePollAction.OnQuestionChanged -> handleOnQuestionChanged(action.question) 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)) _viewEvents.post(CreatePollViewEvents.NotEnoughOptionsError(requiredOptionsCount = MIN_OPTIONS_COUNT))
} }
else -> { 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) _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() { private fun handleOnAddOption() {
setState { setState {
val extendedOptions = options + "" 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 { private fun canCreatePoll(question: String, options: List<String>): Boolean {
return question.isNotEmpty() && return question.isNotEmpty() &&
options.filter { it.isNotEmpty() }.size >= MIN_OPTIONS_COUNT options.filter { it.isNotEmpty() }.size >= MIN_OPTIONS_COUNT

View file

@ -17,16 +17,22 @@
package im.vector.app.features.poll.create package im.vector.app.features.poll.create
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import org.matrix.android.sdk.api.session.room.model.message.PollType
data class CreatePollViewState( data class CreatePollViewState(
val roomId: String, val roomId: String,
val editedEventId: String?,
val mode: PollMode,
val question: String = "", val question: String = "",
val options: List<String> = List(CreatePollViewModel.MIN_OPTIONS_COUNT) { "" }, val options: List<String> = List(CreatePollViewModel.MIN_OPTIONS_COUNT) { "" },
val canCreatePoll: Boolean = false, val canCreatePoll: Boolean = false,
val canAddMoreOptions: Boolean = true val canAddMoreOptions: Boolean = true,
val pollType: PollType = PollType.DISCLOSED
) : MavericksState { ) : MavericksState {
constructor(args: CreatePollArgs) : this( 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 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 // Possible values for TAKE_PHOTO_VIDEO_MODE
const val TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK = 0 const val TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK = 0
const val TAKE_PHOTO_VIDEO_MODE_PHOTO = 1 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) 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_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_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="end_poll_confirmation_approve_button">End poll</string>
<!-- TODO. Remove -->
<string name="labs_enable_polls">Enable Polls</string> <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="poll_end_room_list_preview">Poll ended</string>
<string name="delete_poll_dialog_title">Remove poll</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="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_photo">Open camera</string>
<string name="tooltip_attachment_gallery">Send images and videos</string> <string name="tooltip_attachment_gallery">Send images and videos</string>

View file

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