Allow polls to be edited (#1869)

Polls can be edited if they do not have any votes

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
jonnyandrew
2023-11-24 16:47:58 +00:00
committed by GitHub
parent 198147e813
commit 634d8167ea
50 changed files with 827 additions and 173 deletions

View File

@@ -19,6 +19,8 @@ package io.element.android.features.poll.api
import io.element.android.libraries.matrix.api.poll.PollAnswer
import kotlinx.collections.immutable.persistentListOf
fun aPollQuestion() = "What type of food should we have at the party?"
fun aPollAnswerItemList(
hasVotes: Boolean = true,
isEnded: Boolean = false,
@@ -30,7 +32,7 @@ fun aPollAnswerItemList(
isEnabled = !isEnded,
isWinner = isEnded,
votesCount = if (hasVotes) 5 else 0,
percentage = 0.5f
percentage = if (hasVotes) 0.5f else 0f
),
aPollAnswerItem(
answer = PollAnswer("option_2", "Chinese \uD83C\uDDE8\uD83C\uDDF3"),
@@ -47,9 +49,14 @@ fun aPollAnswerItemList(
isWinner = false,
isSelected = true,
votesCount = if (hasVotes) 1 else 0,
percentage = 0.1f
percentage = if (hasVotes) 0.1f else 0f
),
aPollAnswerItem(
isDisclosed = isDisclosed,
isEnabled = !isEnded,
votesCount = if (hasVotes) 4 else 0,
percentage = if (hasVotes) 0.4f else 0f,
),
aPollAnswerItem(isDisclosed = isDisclosed, isEnabled = !isEnded),
)
fun aPollAnswerItem(

View File

@@ -55,6 +55,7 @@ fun PollContentView(
question: String,
answerItems: ImmutableList<PollAnswerItem>,
pollKind: PollKind,
isPollEditable: Boolean,
isPollEnded: Boolean,
isMine: Boolean,
onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit,
@@ -103,8 +104,8 @@ fun PollContentView(
if (isMine) {
CreatorView(
votesCount = 1, // TODO Polls: set to `votesCount` when edit poll screen is implemented.
isPollEnded = isPollEnded,
isPollEditable = isPollEditable,
onPollEdit = ::onPollEdit,
onPollEnd = { showConfirmation = true },
modifier = Modifier.fillMaxWidth(),
@@ -197,26 +198,25 @@ private fun ColumnScope.UndisclosedPollBottomNotice(
@Composable
private fun CreatorView(
@Suppress("SameParameterValue") votesCount: Int, // TODO Polls: remove @Suppress when edit poll screen is implemented.
isPollEnded: Boolean,
isPollEditable: Boolean,
onPollEdit: () -> Unit,
onPollEnd: () -> Unit,
modifier: Modifier = Modifier
) {
if (!isPollEnded) {
if (votesCount == 0) {
when {
isPollEditable ->
Button(
text = stringResource(id = CommonStrings.action_edit_poll),
onClick = onPollEdit,
modifier = modifier,
)
} else {
!isPollEnded ->
Button(
text = stringResource(id = CommonStrings.action_end_poll),
onClick = onPollEnd,
modifier = modifier,
)
}
}
}
@@ -229,6 +229,7 @@ internal fun PollContentUndisclosedPreview() = ElementPreview {
answerItems = aPollAnswerItemList(isDisclosed = false),
pollKind = PollKind.Undisclosed,
isPollEnded = false,
isPollEditable = false,
isMine = false,
onAnswerSelected = { _, _ -> },
onPollEdit = {},
@@ -245,6 +246,7 @@ internal fun PollContentDisclosedPreview() = ElementPreview {
answerItems = aPollAnswerItemList(),
pollKind = PollKind.Disclosed,
isPollEnded = false,
isPollEditable = false,
isMine = false,
onAnswerSelected = { _, _ -> },
onPollEdit = {},
@@ -261,6 +263,7 @@ internal fun PollContentEndedPreview() = ElementPreview {
answerItems = aPollAnswerItemList(isEnded = true),
pollKind = PollKind.Disclosed,
isPollEnded = true,
isPollEditable = false,
isMine = false,
onAnswerSelected = { _, _ -> },
onPollEdit = {},
@@ -270,13 +273,14 @@ internal fun PollContentEndedPreview() = ElementPreview {
@PreviewsDayNight
@Composable
internal fun PollContentCreatorNoVotesPreview() = ElementPreview {
internal fun PollContentCreatorEditablePreview() = ElementPreview {
PollContentView(
eventId = EventId("\$anEventId"),
question = "What type of food should we have at the party?",
answerItems = aPollAnswerItemList(hasVotes = false, isEnded = false),
pollKind = PollKind.Disclosed,
isPollEnded = false,
isPollEditable = true,
isMine = true,
onAnswerSelected = { _, _ -> },
onPollEdit = {},
@@ -293,6 +297,7 @@ internal fun PollContentCreatorPreview() = ElementPreview {
answerItems = aPollAnswerItemList(isEnded = false),
pollKind = PollKind.Disclosed,
isPollEnded = false,
isPollEditable = false,
isMine = true,
onAnswerSelected = { _, _ -> },
onPollEdit = {},
@@ -309,6 +314,7 @@ internal fun PollContentCreatorEndedPreview() = ElementPreview {
answerItems = aPollAnswerItemList(isEnded = true),
pollKind = PollKind.Disclosed,
isPollEnded = true,
isPollEditable = false,
isMine = true,
onAnswerSelected = { _, _ -> },
onPollEdit = {},

View File

@@ -21,5 +21,14 @@ import com.bumble.appyx.core.node.Node
import io.element.android.libraries.architecture.FeatureEntryPoint
interface CreatePollEntryPoint : FeatureEntryPoint {
fun createNode(parentNode: Node, buildContext: BuildContext): Node
data class Params(
val mode: CreatePollMode,
)
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun params(params: Params): NodeBuilder
fun build(): Node
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 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 io.element.android.features.poll.api.create
import io.element.android.libraries.matrix.api.core.EventId
sealed interface CreatePollMode {
data object NewPoll : CreatePollMode
data class EditPoll(val eventId: EventId) : CreatePollMode
}

View File

@@ -19,7 +19,7 @@ package io.element.android.features.poll.impl.create
import io.element.android.libraries.matrix.api.poll.PollKind
sealed interface CreatePollEvents {
data object Create : CreatePollEvents
data object Save : CreatePollEvents
data class SetQuestion(val question: String) : CreatePollEvents
data class SetAnswer(val index: Int, val text: String) : CreatePollEvents
data object AddAnswer : CreatePollEvents

View File

@@ -0,0 +1,27 @@
/*
* Copyright (c) 2023 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 io.element.android.features.poll.impl.create
internal sealed class CreatePollException : Exception() {
data class GetPollFailed(
override val message: String?, override val cause: Throwable?
) : CreatePollException()
data class SavePollFailed(
override val message: String?, override val cause: Throwable?
) : CreatePollException()
}

View File

@@ -26,6 +26,9 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.services.analytics.api.AnalyticsService
@@ -37,7 +40,11 @@ class CreatePollNode @AssistedInject constructor(
analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
private val presenter = presenterFactory.create(backNavigator = ::navigateUp)
data class Inputs(val mode: CreatePollMode) : NodeInputs
private val inputs: Inputs = inputs()
private val presenter = presenterFactory.create(backNavigator = ::navigateUp, mode = inputs.mode)
init {
lifecycle.subscribe(

View File

@@ -17,6 +17,7 @@
package io.element.android.features.poll.impl.create
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -32,9 +33,11 @@ import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.PollCreation
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.features.poll.impl.data.PollRepository
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -47,26 +50,39 @@ private const val MAX_ANSWER_LENGTH = 240
private const val MAX_SELECTIONS = 1
class CreatePollPresenter @AssistedInject constructor(
private val room: MatrixRoom,
private val repository: PollRepository,
private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext,
@Assisted private val navigateUp: () -> Unit,
@Assisted private val mode: CreatePollMode,
) : Presenter<CreatePollState> {
@AssistedFactory
interface Factory {
fun create(backNavigator: () -> Unit): CreatePollPresenter
fun create(backNavigator: () -> Unit, mode: CreatePollMode): CreatePollPresenter
}
@Composable
override fun present(): CreatePollState {
var question: String by rememberSaveable { mutableStateOf("") }
var answers: List<String> by rememberSaveable() { mutableStateOf(listOf("", "")) }
var answers: List<String> by rememberSaveable { mutableStateOf(listOf("", "")) }
var pollKind: PollKind by rememberSaveable(saver = pollKindSaver) { mutableStateOf(PollKind.Disclosed) }
var showConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
val canCreate: Boolean by remember { derivedStateOf { canCreate(question, answers) } }
LaunchedEffect(Unit) {
if (mode is CreatePollMode.EditPoll) {
repository.getPoll(mode.eventId).onSuccess {
question = it.question
answers = it.answers.map(PollAnswer::text)
pollKind = it.kind
}.onFailure {
analyticsService.trackGetPollFailed(it)
navigateUp()
}
}
}
val canSave: Boolean by remember { derivedStateOf { canSave(question, answers) } }
val canAddAnswer: Boolean by remember { derivedStateOf { canAddAnswer(answers) } }
val immutableAnswers: ImmutableList<Answer> by remember { derivedStateOf { answers.toAnswers() } }
@@ -74,29 +90,25 @@ class CreatePollPresenter @AssistedInject constructor(
fun handleEvents(event: CreatePollEvents) {
when (event) {
is CreatePollEvents.Create -> scope.launch {
if (canCreate) {
room.createPoll(
is CreatePollEvents.Save -> scope.launch {
if (canSave) {
repository.savePoll(
existingPollId = when (mode) {
is CreatePollMode.EditPoll -> mode.eventId
is CreatePollMode.NewPoll -> null
},
question = question,
answers = answers,
maxSelections = MAX_SELECTIONS,
pollKind = pollKind,
)
analyticsService.capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isReply = messageComposerContext.composerMode.isReply,
messageType = Composer.MessageType.Poll,
)
)
analyticsService.capture(
PollCreation(
action = PollCreation.Action.Create,
maxSelections = MAX_SELECTIONS,
).onSuccess {
analyticsService.capturePollSaved(
isUndisclosed = pollKind == PollKind.Undisclosed,
numberOfAnswers = answers.size,
)
)
}.onFailure {
analyticsService.trackSavePollFailed(it, mode)
}
navigateUp()
} else {
Timber.d("Cannot create poll")
@@ -135,7 +147,11 @@ class CreatePollPresenter @AssistedInject constructor(
}
return CreatePollState(
canCreate = canCreate,
mode = when (mode) {
is CreatePollMode.NewPoll -> CreatePollState.Mode.New
is CreatePollMode.EditPoll -> CreatePollState.Mode.Edit
},
canSave = canSave,
canAddAnswer = canAddAnswer,
question = question,
answers = immutableAnswers,
@@ -144,16 +160,61 @@ class CreatePollPresenter @AssistedInject constructor(
eventSink = ::handleEvents,
)
}
private fun AnalyticsService.capturePollSaved(
isUndisclosed: Boolean,
numberOfAnswers: Int,
) {
capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = mode is CreatePollMode.EditPoll,
isReply = messageComposerContext.composerMode.isReply,
messageType = Composer.MessageType.Poll,
)
)
capture(
PollCreation(
action = when (mode) {
is CreatePollMode.EditPoll -> PollCreation.Action.Edit
is CreatePollMode.NewPoll -> PollCreation.Action.Create
},
isUndisclosed = isUndisclosed,
numberOfAnswers = numberOfAnswers,
)
)
}
}
private fun canCreate(
private fun AnalyticsService.trackGetPollFailed(cause: Throwable) {
val exception = CreatePollException.GetPollFailed(
message = "Tried to edit poll but couldn't get poll",
cause = cause,
)
Timber.e(exception)
trackError(exception)
}
private fun AnalyticsService.trackSavePollFailed(cause: Throwable, mode: CreatePollMode) {
val exception = CreatePollException.SavePollFailed(
message = when (mode) {
CreatePollMode.NewPoll -> "Failed to create poll"
is CreatePollMode.EditPoll -> "Failed to edit poll"
},
cause = cause,
)
Timber.e(exception)
trackError(exception)
}
private fun canSave(
question: String,
answers: List<String>
) = question.isNotBlank() && answers.size >= MIN_ANSWERS && answers.all { it.isNotBlank() }
private fun canAddAnswer(answers: List<String>) = answers.size < MAX_ANSWERS
private fun List<String>.toAnswers(): ImmutableList<Answer> {
fun List<String>.toAnswers(): ImmutableList<Answer> {
return map { answer ->
Answer(
text = answer,

View File

@@ -20,14 +20,20 @@ import io.element.android.libraries.matrix.api.poll.PollKind
import kotlinx.collections.immutable.ImmutableList
data class CreatePollState(
val canCreate: Boolean,
val mode: Mode,
val canSave: Boolean,
val canAddAnswer: Boolean,
val question: String,
val answers: ImmutableList<Answer>,
val pollKind: PollKind,
val showConfirmation: Boolean,
val eventSink: (CreatePollEvents) -> Unit,
)
) {
enum class Mode {
New,
Edit,
}
}
data class Answer(
val text: String,

View File

@@ -25,6 +25,7 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
override val values: Sequence<CreatePollState>
get() = sequenceOf(
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = false,
canAddAnswer = true,
question = "",
@@ -36,6 +37,7 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
showConfirmation = false,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
@@ -47,6 +49,7 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
pollKind = PollKind.Undisclosed,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
@@ -58,6 +61,7 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
pollKind = PollKind.Undisclosed,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
@@ -71,6 +75,7 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
pollKind = PollKind.Undisclosed,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = false,
question = "Should there be more than 20 answers?",
@@ -100,6 +105,7 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
pollKind = PollKind.Undisclosed,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = true,
question = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." +
@@ -120,11 +126,24 @@ class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
),
showConfirmation = false,
pollKind = PollKind.Undisclosed,
)
),
aCreatePollState(
mode = CreatePollState.Mode.Edit,
canCreate = false,
canAddAnswer = true,
question = "",
answers = persistentListOf(
Answer("", false),
Answer("", false)
),
pollKind = PollKind.Disclosed,
showConfirmation = false,
),
)
}
private fun aCreatePollState(
mode: CreatePollState.Mode,
canCreate: Boolean,
canAddAnswer: Boolean,
question: String,
@@ -133,7 +152,8 @@ private fun aCreatePollState(
pollKind: PollKind
): CreatePollState {
return CreatePollState(
canCreate = canCreate,
mode = mode,
canSave = canCreate,
canAddAnswer = canAddAnswer,
question = question,
answers = answers,

View File

@@ -47,8 +47,8 @@ import io.element.android.features.poll.impl.R
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
@@ -67,7 +67,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreatePollView(
state: CreatePollState,
@@ -90,23 +89,11 @@ fun CreatePollView(
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(id = R.string.screen_create_poll_title),
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = {
BackButton(onClick = navBack)
},
actions = {
TextButton(
text = stringResource(id = CommonStrings.action_create),
onClick = { state.eventSink(CreatePollEvents.Create) },
enabled = state.canCreate,
)
}
CreatePollTopAppBar(
mode = state.mode,
saveEnabled = state.canSave,
onBackPress = navBack,
onSaveClicked = { state.eventSink(CreatePollEvents.Save) }
)
},
) { paddingValues ->
@@ -210,6 +197,40 @@ fun CreatePollView(
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreatePollTopAppBar(
mode: CreatePollState.Mode,
saveEnabled: Boolean,
onBackPress: () -> Unit = {},
onSaveClicked: () -> Unit = {},
) {
TopAppBar(
title = {
Text(
text = when (mode) {
CreatePollState.Mode.New -> stringResource(id = R.string.screen_create_poll_title)
CreatePollState.Mode.Edit -> stringResource(id = R.string.screen_edit_poll_title)
},
style = ElementTheme.typography.aliasScreenTitle,
)
},
navigationIcon = {
BackButton(onClick = onBackPress)
},
actions = {
TextButton(
text = when (mode) {
CreatePollState.Mode.New -> stringResource(id = CommonStrings.action_create)
CreatePollState.Mode.Edit -> stringResource(id = CommonStrings.action_done)
},
onClick = onSaveClicked,
enabled = saveEnabled,
)
}
)
}
@PreviewsDayNight
@Composable
internal fun CreatePollViewPreview(

View File

@@ -18,6 +18,7 @@ package io.element.android.features.poll.impl.create
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.libraries.architecture.createNode
@@ -26,7 +27,19 @@ import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultCreatePollEntryPoint @Inject constructor() : CreatePollEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<CreatePollNode>(buildContext)
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): CreatePollEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : CreatePollEntryPoint.NodeBuilder {
override fun params(params: CreatePollEntryPoint.Params): CreatePollEntryPoint.NodeBuilder {
plugins += CreatePollNode.Inputs(mode = params.mode)
return this
}
override fun build(): Node {
return parentNode.createNode<CreatePollNode>(buildContext, plugins)
}
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright (c) 2023 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 io.element.android.features.poll.impl.data
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.coroutines.flow.first
import javax.inject.Inject
class PollRepository @Inject constructor(
private val room: MatrixRoom,
) {
suspend fun getPoll(eventId: EventId): Result<PollContent> = runCatching {
room.timeline
.timelineItems
.first()
.asSequence()
.filterIsInstance<MatrixTimelineItem.Event>()
.first { it.eventId == eventId }
.event
.content as PollContent
}
suspend fun savePoll(
existingPollId: EventId?,
question: String,
answers: List<String>,
pollKind: PollKind,
maxSelections: Int,
): Result<Unit> = when (existingPollId) {
null -> room.createPoll(
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
else -> room.editPoll(
pollStartId = existingPollId,
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
}
}

View File

@@ -9,4 +9,5 @@
<string name="screen_create_poll_question_desc">"Question or topic"</string>
<string name="screen_create_poll_question_hint">"What is the poll about?"</string>
<string name="screen_create_poll_title">"Create Poll"</string>
<string name="screen_edit_poll_title">"Edit poll"</string>
</resources>

View File

@@ -18,14 +18,25 @@ package io.element.android.features.poll.impl.create
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth
import im.vector.app.features.analytics.plan.Composer
import im.vector.app.features.analytics.plan.PollCreation
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.features.poll.impl.data.PollRepository
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.test.room.CreatePollInvocation
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.SavePollInvocation
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aPollContent
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
@@ -38,58 +49,73 @@ class CreatePollPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val pollEventId = AN_EVENT_ID
private var navUpInvocationsCount = 0
private val fakeMatrixRoom = FakeMatrixRoom()
private val existingPoll = anExistingPoll()
private val fakeMatrixRoom = createFakeMatrixRoom(existingPoll)
private val fakeAnalyticsService = FakeAnalyticsService()
private val fakeMessageComposerContext = FakeMessageComposerContext()
private val presenter = CreatePollPresenter(
room = fakeMatrixRoom,
analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext,
navigateUp = { navUpInvocationsCount++ },
)
@Test
fun `default state has proper default values`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let {
Truth.assertThat(it.canCreate).isEqualTo(false)
Truth.assertThat(it.canAddAnswer).isEqualTo(true)
Truth.assertThat(it.question).isEqualTo("")
Truth.assertThat(it.answers).isEqualTo(listOf(Answer("", false), Answer("", false)))
Truth.assertThat(it.pollKind).isEqualTo(PollKind.Disclosed)
Truth.assertThat(it.showConfirmation).isEqualTo(false)
}
awaitDefaultItem()
}
}
@Test
fun `in edit mode, poll values are loaded`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
awaitPollLoaded()
}
}
@Test
fun `in edit mode, if poll doesn't exist, error is tracked and screen is closed`() = runTest {
val room = createFakeMatrixRoom(existingPoll = null)
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID), room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
Truth.assertThat(fakeAnalyticsService.trackedErrors.filterIsInstance<CreatePollException.GetPollFailed>()).isNotEmpty()
Truth.assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@Test
fun `non blank question and 2 answers are required to create a poll`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(initial.canCreate).isEqualTo(false)
Truth.assertThat(initial.canSave).isFalse()
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
val questionSet = awaitItem()
Truth.assertThat(questionSet.canCreate).isEqualTo(false)
Truth.assertThat(questionSet.canSave).isFalse()
questionSet.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
val answer1Set = awaitItem()
Truth.assertThat(answer1Set.canCreate).isEqualTo(false)
Truth.assertThat(answer1Set.canSave).isFalse()
answer1Set.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
val answer2Set = awaitItem()
Truth.assertThat(answer2Set.canCreate).isEqualTo(true)
Truth.assertThat(answer2Set.canSave).isTrue()
}
}
@Test
fun `create polls sends a poll start event`() = runTest {
fun `create poll sends a poll start event`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -98,11 +124,11 @@ class CreatePollPresenterTest {
initial.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
initial.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
skipItems(3)
initial.eventSink(CreatePollEvents.Create)
initial.eventSink(CreatePollEvents.Save)
delay(1) // Wait for the coroutine to finish
Truth.assertThat(fakeMatrixRoom.createPollInvocations.size).isEqualTo(1)
Truth.assertThat(fakeMatrixRoom.createPollInvocations.last()).isEqualTo(
CreatePollInvocation(
SavePollInvocation(
question = "A question?",
answers = listOf("Answer 1", "Answer 2"),
maxSelections = 1,
@@ -128,8 +154,104 @@ class CreatePollPresenterTest {
}
}
@Test
fun `when poll creation fails, error is tracked`() = runTest {
val error = Exception("cause")
fakeMatrixRoom.givenCreatePollResult(Result.failure(error))
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem().eventSink(CreatePollEvents.SetQuestion("A question?"))
awaitItem().eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
awaitItem().eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
awaitItem().eventSink(CreatePollEvents.Save)
delay(1) // Wait for the coroutine to finish
Truth.assertThat(fakeMatrixRoom.createPollInvocations).hasSize(1)
Truth.assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
Truth.assertThat(fakeAnalyticsService.trackedErrors).hasSize(1)
Truth.assertThat(fakeAnalyticsService.trackedErrors).containsExactly(
CreatePollException.SavePollFailed("Failed to create poll", error)
)
}
}
@Test
fun `edit poll sends a poll edit event`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
awaitPollLoaded().apply {
eventSink(CreatePollEvents.SetQuestion("Changed question"))
}
awaitItem().apply {
eventSink(CreatePollEvents.SetAnswer(0, "Changed answer 1"))
}
awaitItem().apply {
eventSink(CreatePollEvents.SetAnswer(1, "Changed answer 2"))
}
awaitPollLoaded(
newQuestion = "Changed question",
newAnswer1 = "Changed answer 1",
newAnswer2 = "Changed answer 2",
).apply {
eventSink(CreatePollEvents.Save)
}
delay(1) // Wait for the coroutine to finish
Truth.assertThat(fakeMatrixRoom.editPollInvocations.size).isEqualTo(1)
Truth.assertThat(fakeMatrixRoom.editPollInvocations.last()).isEqualTo(
SavePollInvocation(
question = "Changed question",
answers = listOf("Changed answer 1", "Changed answer 2", "Maybe"),
maxSelections = 1,
pollKind = PollKind.Disclosed
)
)
Truth.assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
Truth.assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
Composer(
inThread = false,
isEditing = true,
isReply = false,
messageType = Composer.MessageType.Poll,
)
)
Truth.assertThat(fakeAnalyticsService.capturedEvents[1]).isEqualTo(
PollCreation(
action = PollCreation.Action.Edit,
isUndisclosed = false,
numberOfAnswers = 3,
)
)
}
}
@Test
fun `when edit poll fails, error is tracked`() = runTest {
val error = Exception("cause")
fakeMatrixRoom.givenEditPollResult(Result.failure(error))
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
awaitPollLoaded().eventSink(CreatePollEvents.SetAnswer(0, "A"))
awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvents.Save)
delay(1) // Wait for the coroutine to finish
Truth.assertThat(fakeMatrixRoom.editPollInvocations).hasSize(1)
Truth.assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
Truth.assertThat(fakeAnalyticsService.trackedErrors).hasSize(1)
Truth.assertThat(fakeAnalyticsService.trackedErrors).containsExactly(
CreatePollException.SavePollFailed("Failed to edit poll", error)
)
}
}
@Test
fun `add answer button adds an empty answer and removing it removes it`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -139,7 +261,7 @@ class CreatePollPresenterTest {
initial.eventSink(CreatePollEvents.AddAnswer)
val answerAdded = awaitItem()
Truth.assertThat(answerAdded.answers.size).isEqualTo(3)
Truth.assertThat(answerAdded.answers[2].text).isEqualTo("")
Truth.assertThat(answerAdded.answers[2].text).isEmpty()
initial.eventSink(CreatePollEvents.RemoveAnswer(2))
val answerRemoved = awaitItem()
@@ -149,6 +271,7 @@ class CreatePollPresenterTest {
@Test
fun `set question sets the question`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -161,6 +284,7 @@ class CreatePollPresenterTest {
@Test
fun `set poll answer sets the given poll answer`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -173,6 +297,7 @@ class CreatePollPresenterTest {
@Test
fun `set poll kind sets the poll kind`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -185,34 +310,37 @@ class CreatePollPresenterTest {
@Test
fun `can add options when between 2 and 20 and then no more`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(initial.canAddAnswer).isEqualTo(true)
Truth.assertThat(initial.canAddAnswer).isTrue()
repeat(17) {
initial.eventSink(CreatePollEvents.AddAnswer)
Truth.assertThat(awaitItem().canAddAnswer).isEqualTo(true)
Truth.assertThat(awaitItem().canAddAnswer).isTrue()
}
initial.eventSink(CreatePollEvents.AddAnswer)
Truth.assertThat(awaitItem().canAddAnswer).isEqualTo(false)
Truth.assertThat(awaitItem().canAddAnswer).isFalse()
}
}
@Test
fun `can delete option if there are more than 2`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(initial.answers.all { it.canDelete }).isEqualTo(false)
Truth.assertThat(initial.answers.all { it.canDelete }).isFalse()
initial.eventSink(CreatePollEvents.AddAnswer)
Truth.assertThat(awaitItem().answers.all { it.canDelete }).isEqualTo(true)
Truth.assertThat(awaitItem().answers.all { it.canDelete }).isTrue()
}
}
@Test
fun `option with more than 240 char is truncated`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -224,6 +352,7 @@ class CreatePollPresenterTest {
@Test
fun `navBack event calls navBack lambda`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -236,31 +365,104 @@ class CreatePollPresenterTest {
@Test
fun `confirm nav back with blank fields calls nav back lambda`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
Truth.assertThat(initial.showConfirmation).isEqualTo(false)
Truth.assertThat(initial.showConfirmation).isFalse()
initial.eventSink(CreatePollEvents.ConfirmNavBack)
Truth.assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@Test
fun `confirm nav back with non blank fields shows confirmation dialog and sending hide hids it`() = runTest {
fun `confirm nav back with non blank fields shows confirmation dialog and sending hides it`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
initial.eventSink(CreatePollEvents.SetQuestion("Non blank"))
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(false)
Truth.assertThat(awaitItem().showConfirmation).isFalse()
initial.eventSink(CreatePollEvents.ConfirmNavBack)
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(true)
Truth.assertThat(awaitItem().showConfirmation).isTrue()
initial.eventSink(CreatePollEvents.HideConfirmation)
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(false)
Truth.assertThat(awaitItem().showConfirmation).isFalse()
}
}
private suspend fun TurbineTestContext<CreatePollState>.awaitDefaultItem() =
awaitItem().apply {
Truth.assertThat(canSave).isFalse()
Truth.assertThat(canAddAnswer).isTrue()
Truth.assertThat(question).isEmpty()
Truth.assertThat(answers).isEqualTo(listOf(Answer("", false), Answer("", false)))
Truth.assertThat(pollKind).isEqualTo(PollKind.Disclosed)
Truth.assertThat(showConfirmation).isFalse()
}
private suspend fun TurbineTestContext<CreatePollState>.awaitPollLoaded(
newQuestion: String? = null,
newAnswer1: String? = null,
newAnswer2: String? = null,
) =
awaitItem().apply {
Truth.assertThat(canSave).isTrue()
Truth.assertThat(canAddAnswer).isTrue()
Truth.assertThat(question).isEqualTo(newQuestion ?: existingPoll.question)
Truth.assertThat(answers).isEqualTo(existingPoll.expectedAnswersState().toMutableList().apply {
newAnswer1?.let { this[0] = Answer(it, true) }
newAnswer2?.let { this[1] = Answer(it, true) }
})
Truth.assertThat(pollKind).isEqualTo(existingPoll.kind)
}
private fun createCreatePollPresenter(
mode: CreatePollMode = CreatePollMode.NewPoll,
room: MatrixRoom = fakeMatrixRoom,
): CreatePollPresenter = CreatePollPresenter(
repository = PollRepository(room),
analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext,
navigateUp = { navUpInvocationsCount++ },
mode = mode,
)
private fun createFakeMatrixRoom(
existingPoll: PollContent? = anExistingPoll(),
) = FakeMatrixRoom(
matrixTimeline = FakeMatrixTimeline(
initialTimelineItems = existingPoll?.let {
listOf(
MatrixTimelineItem.Event(
0,
anEventTimelineItem(
eventId = pollEventId,
content = it,
)
)
)
}.orEmpty()
)
)
}
private fun anExistingPoll() = aPollContent(
question = "Do you like polls?",
answers = listOf(
PollAnswer("1", "Yes"),
PollAnswer("2", "No"),
PollAnswer("2", "Maybe"),
),
)
private fun PollContent.expectedAnswersState() = answers.map { answer ->
Answer(
text = answer.text,
canDelete = answers.size > 2,
)
}