"Create poll" UI (#1143)

NB: This is missing analytics, which will be added once https://github.com/matrix-org/matrix-analytics-events/pull/85 is merged.

Closes https://github.com/vector-im/element-meta/issues/2011
This commit is contained in:
Marco Romano
2023-08-29 22:31:21 +02:00
committed by GitHub
parent 470e135d50
commit b4e6d83fb7
40 changed files with 1032 additions and 112 deletions

1
changelog.d/1143.feature Normal file
View File

@@ -0,0 +1 @@
Create poll.

View File

@@ -46,6 +46,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
@@ -64,6 +65,7 @@ class MessagesFlowNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
private val sendLocationEntryPoint: SendLocationEntryPoint,
private val showLocationEntryPoint: ShowLocationEntryPoint,
private val createPollEntryPoint: CreatePollEntryPoint,
) : BackstackNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Messages,
@@ -101,6 +103,9 @@ class MessagesFlowNode @AssistedInject constructor(
@Parcelize
data object SendLocation : NavTarget
@Parcelize
data object CreatePoll : NavTarget
}
private val callback = plugins<MessagesEntryPoint.Callback>().firstOrNull()
@@ -140,6 +145,10 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onSendLocationClicked() {
backstack.push(NavTarget.SendLocation)
}
override fun onCreatePollClicked() {
backstack.push(NavTarget.CreatePoll)
}
}
createNode<MessagesNode>(buildContext, listOf(callback))
}
@@ -179,6 +188,9 @@ class MessagesFlowNode @AssistedInject constructor(
NavTarget.SendLocation -> {
sendLocationEntryPoint.createNode(this, buildContext)
}
NavTarget.CreatePoll -> {
createPollEntryPoint.createNode(this, buildContext)
}
}
}

View File

@@ -58,6 +58,7 @@ class MessagesNode @AssistedInject constructor(
fun onForwardEventClicked(eventId: EventId)
fun onReportMessage(eventId: EventId, senderId: UserId)
fun onSendLocationClicked()
fun onCreatePollClicked()
}
init {
@@ -99,6 +100,10 @@ class MessagesNode @AssistedInject constructor(
callback?.onSendLocationClicked()
}
private fun onCreatePollClicked() {
callback?.onCreatePollClicked()
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -110,6 +115,7 @@ class MessagesNode @AssistedInject constructor(
onPreviewAttachments = this::onPreviewAttachments,
onUserDataClicked = this::onUserDataClicked,
onSendLocationClicked = this::onSendLocationClicked,
onCreatePollClicked = this::onCreatePollClicked,
modifier = modifier,
)
}

View File

@@ -97,6 +97,7 @@ fun MessagesView(
onUserDataClicked: (UserId) -> Unit,
onPreviewAttachments: (ImmutableList<Attachment>) -> Unit,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
@@ -175,6 +176,7 @@ fun MessagesView(
onReactionLongClicked = ::onEmojiReactionLongClicked,
onMoreReactionsClicked = ::onMoreReactionsClicked,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
onSwipeToReply = { targetEvent ->
state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent))
},
@@ -267,6 +269,7 @@ private fun MessagesViewContent(
onMessageLongClicked: (TimelineItem.Event) -> Unit,
onTimestampClicked: (TimelineItem.Event) -> Unit,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
modifier: Modifier = Modifier,
onSwipeToReply: (TimelineItem.Event) -> Unit,
) {
@@ -295,6 +298,7 @@ private fun MessagesViewContent(
MessageComposerView(
state = state.composerState,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(Alignment.Bottom)
@@ -401,5 +405,6 @@ private fun ContentToPreview(state: MessagesState) {
onPreviewAttachments = {},
onUserDataClicked = {},
onSendLocationClicked = {},
onCreatePollClicked = {},
)
}

View File

@@ -25,6 +25,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
@@ -96,6 +97,22 @@ class ActionListPresenter @Inject constructor(
}
}
}
is TimelineItemPollContent -> {
buildList {
if (timelineItem.content.canBeCopied()) {
add(TimelineItemAction.Copy)
}
if (buildMeta.isDebuggable) {
add(TimelineItemAction.Developer)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
}
if (timelineItem.isMine || userCanRedact) {
add(TimelineItemAction.Redact)
}
}
}
else -> buildList<TimelineItemAction> {
if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server

View File

@@ -24,6 +24,7 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ListItem
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.BarChart
import androidx.compose.material.icons.filled.Collections
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.PhotoCamera
@@ -52,6 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
internal fun AttachmentsBottomSheet(
state: MessageComposerState,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val localView = LocalView.current
@@ -85,6 +87,7 @@ internal fun AttachmentsBottomSheet(
AttachmentSourcePickerMenu(
state = state,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
)
}
}
@@ -95,6 +98,7 @@ internal fun AttachmentsBottomSheet(
internal fun AttachmentSourcePickerMenu(
state: MessageComposerState,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
@@ -131,6 +135,16 @@ internal fun AttachmentSourcePickerMenu(
text = { Text(stringResource(R.string.screen_room_attachment_source_location)) },
)
}
if (state.canCreatePoll) {
ListItem(
modifier = Modifier.clickable {
state.eventSink(MessageComposerEvents.PickAttachmentSource.Poll)
onCreatePollClicked()
},
icon = { Icon(Icons.Default.BarChart, null) },
text = { Text(stringResource(R.string.screen_room_attachment_source_poll)) },
)
}
}
}
@@ -142,5 +156,6 @@ internal fun AttachmentSourcePickerMenuPreview() = ElementPreview {
canShareLocation = true,
),
onSendLocationClicked = {},
onCreatePollClicked = {},
)
}

View File

@@ -35,6 +35,7 @@ sealed interface MessageComposerEvents {
data object PhotoFromCamera : PickAttachmentSource
data object VideoFromCamera : PickAttachmentSource
data object Location : PickAttachmentSource
data object Poll : PickAttachmentSource
}
data object CancelSendAttachment : MessageComposerEvents
}

View File

@@ -83,6 +83,11 @@ class MessageComposerPresenter @Inject constructor(
canShareLocation.value = featureFlagService.isFeatureEnabled(FeatureFlags.LocationSharing)
}
val canCreatePoll = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
canCreatePoll.value = featureFlagService.isFeatureEnabled(FeatureFlags.Polls)
}
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(attachmentsState, uri, mimeType)
}
@@ -179,6 +184,10 @@ class MessageComposerPresenter @Inject constructor(
showAttachmentSourcePicker = false
// Navigation to the location picker screen is done at the view layer
}
MessageComposerEvents.PickAttachmentSource.Poll -> {
showAttachmentSourcePicker = false
// Navigation to the create poll screen is done at the view layer
}
is MessageComposerEvents.CancelSendAttachment -> {
ongoingSendAttachmentJob.value?.let {
it.cancel()
@@ -195,6 +204,7 @@ class MessageComposerPresenter @Inject constructor(
mode = messageComposerContext.composerMode,
showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation.value,
canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value,
eventSink = ::handleEvents
)

View File

@@ -29,6 +29,7 @@ data class MessageComposerState(
val mode: MessageComposerMode,
val showAttachmentSourcePicker: Boolean,
val canShareLocation: Boolean,
val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState,
val eventSink: (MessageComposerEvents) -> Unit
) {

View File

@@ -33,6 +33,7 @@ fun aMessageComposerState(
mode: MessageComposerMode = MessageComposerMode.Normal(content = ""),
showAttachmentSourcePicker: Boolean = false,
canShareLocation: Boolean = true,
canCreatePoll: Boolean = true,
attachmentsState: AttachmentsState = AttachmentsState.None,
) = MessageComposerState(
text = text,
@@ -41,6 +42,7 @@ fun aMessageComposerState(
mode = mode,
showAttachmentSourcePicker = showAttachmentSourcePicker,
canShareLocation = canShareLocation,
canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState,
eventSink = {},
)

View File

@@ -29,6 +29,7 @@ import io.element.android.libraries.textcomposer.TextComposer
fun MessageComposerView(
state: MessageComposerState,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onFullscreenToggle() {
@@ -59,6 +60,7 @@ fun MessageComposerView(
AttachmentsBottomSheet(
state = state,
onSendLocationClicked = onSendLocationClicked,
onCreatePollClicked = onCreatePollClicked,
)
TextComposer(
@@ -88,6 +90,7 @@ internal fun MessageComposerViewDarkPreview(@PreviewParameter(MessageComposerSta
private fun ContentToPreview(state: MessageComposerState) {
MessageComposerView(
state = state,
onSendLocationClicked = {}
onSendLocationClicked = {},
onCreatePollClicked = {},
)
}

View File

@@ -26,10 +26,14 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.ActionListState
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.poll.api.PollAnswerItem
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.A_MESSAGE
import io.element.android.libraries.matrix.test.core.aBuildMeta
import kotlinx.collections.immutable.persistentListOf
@@ -369,6 +373,57 @@ class ActionListPresenterTest {
assertThat(successState.displayEmojiReactions).isFalse()
}
}
@Test
fun `present - compute for poll message`() = runTest {
val presenter = anActionListPresenter(isBuildDebuggable = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemPollContent(
question = "Some question?",
answerItems = listOf(
PollAnswerItem(
answer = PollAnswer("id_1", "Answer1"),
isSelected = false,
isEnabled = false,
isWinner = false,
isDisclosed = false,
votesCount = 0,
percentage = 0.0f,
),
PollAnswerItem(
answer = PollAnswer("id_2", "Answer2"),
isSelected = false,
isEnabled = false,
isWinner = false,
isDisclosed = false,
votesCount = 0,
percentage = 0.0f,
),
),
votes = mapOf(),
pollKind = PollKind.Disclosed,
isEnded = false,
)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, false))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Redact,
)
)
)
assertThat(successState.displayEmojiReactions).isTrue()
}
}
}
private fun anActionListPresenter(isBuildDebuggable: Boolean) = ActionListPresenter(buildMeta = aBuildMeta(isDebuggable = isBuildDebuggable))

View File

@@ -14,24 +14,12 @@
* limitations under the License.
*/
package io.element.android.features.poll.api
package io.element.android.features.poll.api.create
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
interface PollEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
// Add your callbacks
}
interface CreatePollEntryPoint : FeatureEntryPoint {
fun createNode(parentNode: Node, buildContext: BuildContext): Node
}

View File

@@ -14,8 +14,6 @@
* limitations under the License.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
@@ -40,6 +38,8 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.services.analytics.api)
implementation(projects.libraries.uiStrings)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
@@ -47,6 +47,7 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
ksp(libs.showkase.processor)
}

View File

@@ -1,70 +0,0 @@
/*
* 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
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class PollFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BackstackNode<PollFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
createNode(buildContext)
}
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler(),
)
}
}

View File

@@ -0,0 +1,31 @@
/*
* 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
import io.element.android.libraries.matrix.api.poll.PollKind
sealed interface CreatePollEvents {
data object Create : CreatePollEvents
data class SetQuestion(val question: String) : CreatePollEvents
data class SetAnswer(val index: Int, val text: String) : CreatePollEvents
data object AddAnswer : CreatePollEvents
data class RemoveAnswer(val index: Int) : CreatePollEvents
data class SetPollKind(val pollKind: PollKind) : CreatePollEvents
data object NavBack : CreatePollEvents
data object ConfirmNavBack : CreatePollEvents
data object HideConfirmation : CreatePollEvents
}

View File

@@ -0,0 +1,56 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
class CreatePollNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: CreatePollPresenter.Factory,
// analyticsService: AnalyticsService, // TODO Polls: add analytics
) : Node(buildContext, plugins = plugins) {
private val presenter = presenterFactory.create(backNavigator = ::navigateUp)
init {
lifecycle.subscribe(
onResume = {
// TODO Polls: add analytics
// analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.PollView))
}
)
}
@Composable
override fun View(modifier: Modifier) {
CreatePollView(
state = presenter.present(),
modifier = modifier,
)
}
}

View File

@@ -0,0 +1,162 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.api.room.MatrixRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import timber.log.Timber
private const val MIN_ANSWERS = 2
private const val MAX_ANSWERS = 20
private const val MAX_ANSWER_LENGTH = 240
private const val MAX_SELECTIONS = 1
class CreatePollPresenter @AssistedInject constructor(
private val room: MatrixRoom,
// private val analyticsService: AnalyticsService, // TODO Polls: add analytics
@Assisted private val navigateUp: () -> Unit,
// private val messageComposerContext: MessageComposerContext, // TODO Polls: add analytics
) : Presenter<CreatePollState> {
@AssistedFactory
interface Factory {
fun create(backNavigator: () -> Unit): CreatePollPresenter
}
@Composable
override fun present(): CreatePollState {
var question: String by rememberSaveable { mutableStateOf("") }
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) } }
val canAddAnswer: Boolean by remember { derivedStateOf { canAddAnswer(answers) } }
val immutableAnswers: ImmutableList<Answer> by remember { derivedStateOf { answers.toAnswers() } }
val scope = rememberCoroutineScope()
fun handleEvents(event: CreatePollEvents) {
when (event) {
is CreatePollEvents.Create -> scope.launch {
if (canCreate) {
room.createPoll(
question = question,
answers = answers,
maxSelections = MAX_SELECTIONS,
pollKind = pollKind,
)
// analyticsService.capture(PollCreate()) // TODO Polls: add analytics
navigateUp()
} else {
Timber.d("Cannot create poll")
}
}
is CreatePollEvents.AddAnswer -> {
answers = answers + ""
}
is CreatePollEvents.RemoveAnswer -> {
answers = answers.filterIndexed { index, _ -> index != event.index }
}
is CreatePollEvents.SetAnswer -> {
answers = answers.toMutableList().apply {
this[event.index] = event.text.take(MAX_ANSWER_LENGTH)
}
}
is CreatePollEvents.SetPollKind -> {
pollKind = event.pollKind
}
is CreatePollEvents.SetQuestion -> {
question = event.question
}
is CreatePollEvents.NavBack -> {
navigateUp()
}
CreatePollEvents.ConfirmNavBack -> {
val shouldConfirm = question.isNotBlank() || answers.any { it.isNotBlank() }
if (shouldConfirm) {
showConfirmation = true
} else {
navigateUp()
}
}
is CreatePollEvents.HideConfirmation -> showConfirmation = false
}
}
return CreatePollState(
canCreate = canCreate,
canAddAnswer = canAddAnswer,
question = question,
answers = immutableAnswers,
pollKind = pollKind,
showConfirmation = showConfirmation,
eventSink = ::handleEvents,
)
}
}
private fun canCreate(
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> {
return map { answer ->
Answer(
text = answer,
canDelete = this.size > MIN_ANSWERS,
)
}.toImmutableList()
}
private val pollKindSaver: Saver<MutableState<PollKind>, Boolean> = Saver(
save = {
when (it.value) {
PollKind.Disclosed -> false
PollKind.Undisclosed -> true
}
},
restore = {
mutableStateOf(
when(it) {
true -> PollKind.Undisclosed
else -> PollKind.Disclosed
}
)
}
)

View File

@@ -0,0 +1,35 @@
/*
* 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
import io.element.android.libraries.matrix.api.poll.PollKind
import kotlinx.collections.immutable.ImmutableList
data class CreatePollState(
val canCreate: Boolean,
val canAddAnswer: Boolean,
val question: String,
val answers: ImmutableList<Answer>,
val pollKind: PollKind,
val showConfirmation: Boolean,
val eventSink: (CreatePollEvents) -> Unit = {},
)
data class Answer(
val text: String,
val canDelete: Boolean,
)

View File

@@ -0,0 +1,124 @@
/*
* 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
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.poll.PollKind
import kotlinx.collections.immutable.persistentListOf
class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
override val values: Sequence<CreatePollState>
get() = sequenceOf(
CreatePollState(
canCreate = false,
canAddAnswer = true,
question = "",
answers = persistentListOf(
Answer("", false),
Answer("", false)
),
pollKind = PollKind.Disclosed,
showConfirmation = false,
),
CreatePollState(
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
answers = persistentListOf(
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
),
showConfirmation = false,
pollKind = PollKind.Undisclosed,
),
CreatePollState(
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
answers = persistentListOf(
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
),
showConfirmation = true,
pollKind = PollKind.Undisclosed,
),
CreatePollState(
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
answers = persistentListOf(
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", true),
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", true),
Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true),
Answer("French \uD83C\uDDEB\uD83C\uDDF7", true),
),
showConfirmation = false,
pollKind = PollKind.Undisclosed,
),
CreatePollState(
canCreate = true,
canAddAnswer = false,
question = "Should there be more than 20 answers?",
answers = persistentListOf(
Answer("1", true),
Answer("2", true),
Answer("3", true),
Answer("4", true),
Answer("5", true),
Answer("6", true),
Answer("7", true),
Answer("8", true),
Answer("9", true),
Answer("10", true),
Answer("11", true),
Answer("12", true),
Answer("13", true),
Answer("14", true),
Answer("15", true),
Answer("16", true),
Answer("17", true),
Answer("18", true),
Answer("19", true),
Answer("20", true),
),
showConfirmation = false,
pollKind = PollKind.Undisclosed,
),
CreatePollState(
canCreate = true,
canAddAnswer = true,
question = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." +
" Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor" +
" in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt" +
" in culpa qui officia deserunt mollit anim id est laborum.",
answers = persistentListOf(
Answer(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." +
" Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis a.",
false
),
Answer(
"Laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore" +
" eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mol.",
false
),
),
showConfirmation = false,
pollKind = PollKind.Undisclosed,
)
)
}

View File

@@ -0,0 +1,187 @@
/*
* 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
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.poll.impl.R
import io.element.android.libraries.designsystem.VectorIcons
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.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
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
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreatePollView(
state: CreatePollState,
modifier: Modifier = Modifier,
) {
val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) }
BackHandler(onBack = navBack)
if (state.showConfirmation) ConfirmationDialog(
content = stringResource(id = R.string.screen_create_poll_confirmation),
onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) },
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
)
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,
)
}
)
},
) { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.imePadding()
.fillMaxSize(),
) {
item {
Text(
text = stringResource(id = R.string.screen_create_poll_question_desc),
modifier = Modifier.padding(start = 32.dp),
style = ElementTheme.typography.fontBodyMdRegular,
)
}
item {
ListItem(
headlineContent = {
OutlinedTextField(
value = state.question,
onValueChange = {
state.eventSink(CreatePollEvents.SetQuestion(it))
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(text = stringResource(id = R.string.screen_create_poll_question_hint))
},
)
}
)
}
itemsIndexed(state.answers) { index, answer ->
ListItem(
headlineContent = {
OutlinedTextField(
value = answer.text,
onValueChange = {
state.eventSink(CreatePollEvents.SetAnswer(index, it))
},
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(text = stringResource(id = R.string.screen_create_poll_answer_hint, index + 1))
},
)
},
trailingContent = ListItemContent.Custom {
Icon(
resourceId = VectorIcons.Delete,
contentDescription = null,
modifier = Modifier.clickable(answer.canDelete) {
state.eventSink(CreatePollEvents.RemoveAnswer(index))
},
)
},
style = if (answer.canDelete) ListItemStyle.Destructive else ListItemStyle.Default,
)
}
if (state.canAddAnswer) {
item {
ListItem(
headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_add_option_btn)) },
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(Icons.Default.Add),
),
style = ListItemStyle.Primary,
onClick = { state.eventSink(CreatePollEvents.AddAnswer) },
)
}
}
item {
HorizontalDivider()
}
item {
ListItem(
headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) },
supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) },
trailingContent = ListItemContent.Switch(
checked = state.pollKind == PollKind.Undisclosed,
onChange = { state.eventSink(CreatePollEvents.SetPollKind(if (it) PollKind.Undisclosed else PollKind.Disclosed)) },
),
)
}
}
}
}
@DayNightPreviews
@Composable
internal fun CreatePollViewPreview(
@PreviewParameter(CreatePollStateProvider::class) state: CreatePollState
) = ElementPreview {
CreatePollView(
state = state,
)
}

View File

@@ -14,33 +14,19 @@
* limitations under the License.
*/
package io.element.android.features.poll.impl
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.PollEntryPoint
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultPollEntryPoint @Inject constructor() : PollEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): PollEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : PollEntryPoint.NodeBuilder {
override fun callback(callback: PollEntryPoint.Callback): PollEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<PollFlowNode>(buildContext, plugins)
}
}
class DefaultCreatePollEntryPoint @Inject constructor() : CreatePollEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<CreatePollNode>(buildContext)
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_poll_add_option_btn">"Add option"</string>
<string name="screen_create_poll_anonymous_desc">"Show results only after poll ends"</string>
<string name="screen_create_poll_anonymous_headline">"Anonymous Poll"</string>
<string name="screen_create_poll_answer_hint">"Option %1$d"</string>
<string name="screen_create_poll_confirmation">"Are you sure you would like to go back?"</string>
<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>
</resources>

View File

@@ -0,0 +1,239 @@
/*
* 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
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.matrix.test.room.CreatePollInvocation
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Test
class CreatePollPresenterTest {
private var navUpInvocationsCount = 0
private val fakeMatrixRoom = FakeMatrixRoom()
// private val fakeAnalyticsService = FakeAnalyticsService() // TODO Polls: add analytics
private val presenter = CreatePollPresenter(
room = fakeMatrixRoom,
// analyticsService = fakeAnalyticsService, // TODO Polls: add analytics
navigateUp = { navUpInvocationsCount++ },
)
@Test
fun `default state has proper default values`() = runTest {
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)
}
}
}
@Test
fun `non blank question and 2 answers are required to create a poll`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(initial.canCreate).isEqualTo(false)
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
val questionSet = awaitItem()
Truth.assertThat(questionSet.canCreate).isEqualTo(false)
questionSet.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
val answer1Set = awaitItem()
Truth.assertThat(answer1Set.canCreate).isEqualTo(false)
answer1Set.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
val answer2Set = awaitItem()
Truth.assertThat(answer2Set.canCreate).isEqualTo(true)
}
}
@Test
fun `create polls sends a poll start event`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
initial.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
initial.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
skipItems(3)
initial.eventSink(CreatePollEvents.Create)
delay(1) // Wait for the coroutine to finish
Truth.assertThat(fakeMatrixRoom.createPollInvocations.size).isEqualTo(1)
Truth.assertThat(fakeMatrixRoom.createPollInvocations.last()).isEqualTo(
CreatePollInvocation(
question = "A question?",
answers = listOf("Answer 1", "Answer 2"),
maxSelections = 1,
pollKind = PollKind.Disclosed
)
)
}
}
@Test
fun `add answer button adds an empty answer and removing it removes it`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(initial.answers.size).isEqualTo(2)
initial.eventSink(CreatePollEvents.AddAnswer)
val answerAdded = awaitItem()
Truth.assertThat(answerAdded.answers.size).isEqualTo(3)
Truth.assertThat(answerAdded.answers[2].text).isEqualTo("")
initial.eventSink(CreatePollEvents.RemoveAnswer(2))
val answerRemoved = awaitItem()
Truth.assertThat(answerRemoved.answers.size).isEqualTo(2)
}
}
@Test
fun `set question sets the question`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
val questionSet = awaitItem()
Truth.assertThat(questionSet.question).isEqualTo("A question?")
}
}
@Test
fun `set poll answer sets the given poll answer`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
initial.eventSink(CreatePollEvents.SetAnswer(0, "This is answer 1"))
val answerSet = awaitItem()
Truth.assertThat(answerSet.answers.first().text).isEqualTo("This is answer 1")
}
}
@Test
fun `set poll kind sets the poll kind`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
initial.eventSink(CreatePollEvents.SetPollKind(PollKind.Undisclosed))
val kindSet = awaitItem()
Truth.assertThat(kindSet.pollKind).isEqualTo(PollKind.Undisclosed)
}
}
@Test
fun `can add options when between 2 and 20 and then no more`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(initial.canAddAnswer).isEqualTo(true)
repeat(17) {
initial.eventSink(CreatePollEvents.AddAnswer)
Truth.assertThat(awaitItem().canAddAnswer).isEqualTo(true)
}
initial.eventSink(CreatePollEvents.AddAnswer)
Truth.assertThat(awaitItem().canAddAnswer).isEqualTo(false)
}
}
@Test
fun `can delete option if there are more than 2`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(initial.answers.all { it.canDelete }).isEqualTo(false)
initial.eventSink(CreatePollEvents.AddAnswer)
Truth.assertThat(awaitItem().answers.all { it.canDelete }).isEqualTo(true)
}
}
@Test
fun `option with more than 240 char is truncated`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
initial.eventSink(CreatePollEvents.SetAnswer(0, "A".repeat(241)))
Truth.assertThat(awaitItem().answers.first().text.length).isEqualTo(240)
}
}
@Test
fun `navBack event calls navBack lambda`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
initial.eventSink(CreatePollEvents.NavBack)
Truth.assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@Test
fun `confirm nav back with blank fields calls nav back lambda`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
Truth.assertThat(initial.showConfirmation).isEqualTo(false)
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 {
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)
initial.eventSink(CreatePollEvents.ConfirmNavBack)
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(true)
initial.eventSink(CreatePollEvents.HideConfirmation)
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(false)
}
}
}

View File

@@ -29,7 +29,7 @@ enum class FeatureFlags(
Polls(
key = "feature.polls",
title = "Polls",
description = "Render poll events in the timeline",
description = "Create poll and render poll events in the timeline",
defaultValue = false,
)
}

View File

@@ -120,6 +120,12 @@
"screen_welcome_.*",
"screen_migration_.*"
]
},
{
"name": ":features:poll:impl",
"includeRegex": [
"screen_create_poll_.*"
]
}
]
}