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 4e52244b86
commit 8fcec4a006
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 827 additions and 173 deletions

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>