First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.poll.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
api(projects.features.poll.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.services.analytics.api)
implementation(projects.features.messages.api)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.uiStrings)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.features.poll.test)
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl
internal object PollConstants {
const val MIN_ANSWERS = 2
const val MAX_ANSWERS = 20
const val MAX_ANSWER_LENGTH = 240
const val MAX_SELECTIONS = 1
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.actions
import dev.zacsweers.metro.ContributesBinding
import im.vector.app.features.analytics.plan.PollEnd
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService
@ContributesBinding(RoomScope::class)
class DefaultEndPollAction(
private val analyticsService: AnalyticsService,
) : EndPollAction {
override suspend fun execute(timeline: Timeline, pollStartId: EventId): Result<Unit> {
return timeline.endPoll(
pollStartId = pollStartId,
text = "The poll with event id: $pollStartId has ended."
).onSuccess {
analyticsService.capture(PollEnd())
}
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.actions
import dev.zacsweers.metro.ContributesBinding
import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService
@ContributesBinding(RoomScope::class)
class DefaultSendPollResponseAction(
private val analyticsService: AnalyticsService,
) : SendPollResponseAction {
override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result<Unit> {
return timeline.sendPollResponse(
pollStartId = pollStartId,
answers = listOf(answerId),
).onSuccess {
analyticsService.capture(PollVote())
}
}
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.create
import io.element.android.libraries.matrix.api.poll.PollKind
sealed interface CreatePollEvents {
data object Save : CreatePollEvents
data class Delete(val confirmed: Boolean) : 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
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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()
}
@@ -0,0 +1,68 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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 dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.MobileScreen
import io.element.android.annotations.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.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService
import java.util.concurrent.atomic.AtomicBoolean
@ContributesNode(RoomScope::class)
@AssistedInject
class CreatePollNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: CreatePollPresenter.Factory,
analyticsService: AnalyticsService,
) : Node(buildContext, plugins = plugins) {
data class Inputs(val mode: CreatePollMode, val timelineMode: Timeline.Mode) : NodeInputs
private val inputs: Inputs = inputs()
private var isNavigatingUp = AtomicBoolean(false)
private val presenter = presenterFactory.create(
backNavigator = {
if (isNavigatingUp.compareAndSet(false, true)) {
navigateUp()
}
},
mode = inputs.mode,
timelineMode = inputs.timelineMode,
)
init {
lifecycle.subscribe(
onResume = {
analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.CreatePollView))
}
)
}
@Composable
override fun View(modifier: Modifier) {
CreatePollView(
state = presenter.present(),
modifier = modifier,
)
}
}
@@ -0,0 +1,244 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.create
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.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.PollConstants.MAX_SELECTIONS
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.poll.isDisclosed
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import timber.log.Timber
@AssistedInject
class CreatePollPresenter(
repositoryFactory: PollRepository.Factory,
private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContext,
@Assisted private val navigateUp: () -> Unit,
@Assisted private val mode: CreatePollMode,
@Assisted private val timelineMode: Timeline.Mode,
) : Presenter<CreatePollState> {
@AssistedFactory
fun interface Factory {
fun create(
timelineMode: Timeline.Mode,
backNavigator: () -> Unit,
mode: CreatePollMode
): CreatePollPresenter
}
private val repository = repositoryFactory.create(timelineMode)
@Composable
override fun present(): CreatePollState {
// The initial state of the form. In edit mode this will be populated with the poll being edited.
var initialPoll: PollFormState by rememberSaveable(stateSaver = pollFormStateSaver) {
mutableStateOf(PollFormState.Empty)
}
// The current state of the form.
var poll: PollFormState by rememberSaveable(stateSaver = pollFormStateSaver) {
mutableStateOf(initialPoll)
}
// Whether the form has been changed from the initial state
val isDirty: Boolean by remember { derivedStateOf { poll != initialPoll } }
var showBackConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
var showDeleteConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (mode is CreatePollMode.EditPoll) {
repository.getPoll(mode.eventId).onSuccess {
val loadedPoll = PollFormState(
question = it.question,
answers = it.answers.map(PollAnswer::text).toImmutableList(),
isDisclosed = it.kind.isDisclosed,
)
initialPoll = loadedPoll
poll = loadedPoll
}.onFailure {
analyticsService.trackGetPollFailed(it)
navigateUp()
}
}
}
val canSave: Boolean by remember { derivedStateOf { poll.isValid } }
val canAddAnswer: Boolean by remember { derivedStateOf { poll.canAddAnswer } }
val immutableAnswers: ImmutableList<Answer> by remember { derivedStateOf { poll.toUiAnswers() } }
val scope = rememberCoroutineScope()
fun handleEvent(event: CreatePollEvents) {
when (event) {
is CreatePollEvents.Save -> scope.launch {
if (canSave) {
repository.savePoll(
existingPollId = when (mode) {
is CreatePollMode.EditPoll -> mode.eventId
is CreatePollMode.NewPoll -> null
},
question = poll.question,
answers = poll.answers,
pollKind = poll.pollKind,
maxSelections = MAX_SELECTIONS,
).onSuccess {
analyticsService.capturePollSaved(
isUndisclosed = poll.pollKind == PollKind.Undisclosed,
numberOfAnswers = poll.answers.size,
)
}.onFailure {
analyticsService.trackSavePollFailed(it, mode)
}
navigateUp()
} else {
Timber.d("Cannot create poll")
}
}
is CreatePollEvents.Delete -> {
if (mode !is CreatePollMode.EditPoll) {
return
}
if (!event.confirmed) {
showDeleteConfirmation = true
return
}
scope.launch {
showDeleteConfirmation = false
repository.deletePoll(mode.eventId)
navigateUp()
}
}
is CreatePollEvents.AddAnswer -> {
poll = poll.withNewAnswer()
}
is CreatePollEvents.RemoveAnswer -> {
poll = poll.withAnswerRemoved(event.index)
}
is CreatePollEvents.SetAnswer -> {
poll = poll.withAnswerChanged(event.index, event.text)
}
is CreatePollEvents.SetPollKind -> {
poll = poll.copy(isDisclosed = event.pollKind.isDisclosed)
}
is CreatePollEvents.SetQuestion -> {
poll = poll.copy(question = event.question)
}
is CreatePollEvents.NavBack -> {
navigateUp()
}
CreatePollEvents.ConfirmNavBack -> {
val shouldConfirm = isDirty
if (shouldConfirm) {
showBackConfirmation = true
} else {
navigateUp()
}
}
is CreatePollEvents.HideConfirmation -> {
showBackConfirmation = false
showDeleteConfirmation = false
}
}
}
return CreatePollState(
mode = when (mode) {
is CreatePollMode.NewPoll -> CreatePollState.Mode.New
is CreatePollMode.EditPoll -> CreatePollState.Mode.Edit
},
canSave = canSave,
canAddAnswer = canAddAnswer,
question = poll.question,
answers = immutableAnswers,
pollKind = poll.pollKind,
showBackConfirmation = showBackConfirmation,
showDeleteConfirmation = showDeleteConfirmation,
eventSink = ::handleEvent,
)
}
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 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)
}
fun PollFormState.toUiAnswers(): ImmutableList<Answer> {
return answers.map { answer ->
Answer(
text = answer,
canDelete = canDeleteAnswer,
)
}.toImmutableList()
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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 mode: Mode,
val canSave: Boolean,
val canAddAnswer: Boolean,
val question: String,
val answers: ImmutableList<Answer>,
val pollKind: PollKind,
val showBackConfirmation: Boolean,
val showDeleteConfirmation: Boolean,
val eventSink: (CreatePollEvents) -> Unit,
) {
enum class Mode {
New,
Edit,
}
val canDelete: Boolean = mode == Mode.Edit
}
data class Answer(
val text: String,
val canDelete: Boolean,
)
@@ -0,0 +1,177 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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.toImmutableList
class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
override val values: Sequence<CreatePollState>
get() = sequenceOf(
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = false,
canAddAnswer = true,
question = "",
answers = listOf(
Answer("", false),
Answer("", false)
),
pollKind = PollKind.Disclosed,
showBackConfirmation = false,
showDeleteConfirmation = false,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
answers = listOf(
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
),
showBackConfirmation = false,
showDeleteConfirmation = false,
pollKind = PollKind.Undisclosed,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
answers = listOf(
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
),
showBackConfirmation = true,
showDeleteConfirmation = false,
pollKind = PollKind.Undisclosed,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = true,
question = "What type of food should we have?",
answers = listOf(
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),
),
showBackConfirmation = false,
showDeleteConfirmation = false,
pollKind = PollKind.Undisclosed,
),
aCreatePollState(
mode = CreatePollState.Mode.New,
canCreate = true,
canAddAnswer = false,
question = "Should there be more than 20 answers?",
answers = listOf(
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),
),
showBackConfirmation = false,
showDeleteConfirmation = false,
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." +
" 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 = listOf(
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
),
),
showBackConfirmation = false,
showDeleteConfirmation = false,
pollKind = PollKind.Undisclosed,
),
aCreatePollState(
mode = CreatePollState.Mode.Edit,
canCreate = false,
canAddAnswer = true,
question = "",
answers = listOf(
Answer("", false),
Answer("", false)
),
pollKind = PollKind.Disclosed,
showDeleteConfirmation = false,
showBackConfirmation = false,
),
aCreatePollState(
mode = CreatePollState.Mode.Edit,
canCreate = false,
canAddAnswer = true,
question = "",
answers = listOf(
Answer("", false),
Answer("", false)
),
pollKind = PollKind.Disclosed,
showDeleteConfirmation = true,
showBackConfirmation = false,
),
)
}
private fun aCreatePollState(
mode: CreatePollState.Mode,
canCreate: Boolean,
canAddAnswer: Boolean,
question: String,
answers: List<Answer>,
showBackConfirmation: Boolean,
showDeleteConfirmation: Boolean,
pollKind: PollKind
): CreatePollState {
return CreatePollState(
mode = mode,
canSave = canCreate,
canAddAnswer = canAddAnswer,
question = question,
answers = answers.toImmutableList(),
showBackConfirmation = showBackConfirmation,
showDeleteConfirmation = showDeleteConfirmation,
pollKind = pollKind,
eventSink = {}
)
}
@@ -0,0 +1,244 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.create
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
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.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
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.dialogs.SaveChangesDialog
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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.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.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.poll.PollKind
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun CreatePollView(
state: CreatePollState,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) }
BackHandler(onBack = navBack)
if (state.showBackConfirmation) {
SaveChangesDialog(
onSubmitClick = { state.eventSink(CreatePollEvents.NavBack) },
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
)
}
if (state.showDeleteConfirmation) {
ConfirmationDialog(
title = stringResource(id = R.string.screen_edit_poll_delete_confirmation_title),
content = stringResource(id = R.string.screen_edit_poll_delete_confirmation),
onSubmitClick = { state.eventSink(CreatePollEvents.Delete(confirmed = true)) },
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
)
}
val questionFocusRequester = remember { FocusRequester() }
val answerFocusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) {
questionFocusRequester.requestFocus()
}
Scaffold(
modifier = modifier,
topBar = {
CreatePollTopAppBar(
mode = state.mode,
saveEnabled = state.canSave,
onBackClick = navBack,
onSaveClick = { state.eventSink(CreatePollEvents.Save) }
)
},
) { paddingValues ->
val lazyListState = rememberLazyListState()
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.imePadding()
.fillMaxSize(),
state = lazyListState,
) {
item {
Column {
ListItem(
headlineContent = {
TextField(
label = stringResource(id = R.string.screen_create_poll_question_desc),
value = state.question,
onValueChange = {
state.eventSink(CreatePollEvents.SetQuestion(it))
},
modifier = Modifier
.focusRequester(questionFocusRequester)
.fillMaxWidth(),
placeholder = stringResource(id = R.string.screen_create_poll_question_hint),
keyboardOptions = keyboardOptions,
)
}
)
}
}
itemsIndexed(state.answers) { index, answer ->
val isLastItem = index == state.answers.size - 1
ListItem(
headlineContent = {
TextField(
value = answer.text,
onValueChange = {
state.eventSink(CreatePollEvents.SetAnswer(index, it))
},
modifier = Modifier
.then(if (isLastItem) Modifier.focusRequester(answerFocusRequester) else Modifier)
.fillMaxWidth(),
placeholder = stringResource(id = R.string.screen_create_poll_answer_hint, index + 1),
keyboardOptions = keyboardOptions,
)
},
trailingContent = ListItemContent.Custom {
Icon(
imageVector = CompoundIcons.Delete(),
contentDescription = stringResource(R.string.screen_create_poll_delete_option_a11y, answer.text),
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(CompoundIcons.Plus()),
),
style = ListItemStyle.Primary,
onClick = {
state.eventSink(CreatePollEvents.AddAnswer)
coroutineScope.launch(Dispatchers.Main) {
lazyListState.animateScrollToItem(state.answers.size + 1)
answerFocusRequester.requestFocus()
}
},
)
}
}
item {
Column {
HorizontalDivider()
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,
),
onClick = {
state.eventSink(
CreatePollEvents.SetPollKind(
if (state.pollKind == PollKind.Disclosed) PollKind.Undisclosed else PollKind.Disclosed
)
)
},
)
if (state.canDelete) {
ListItem(
headlineContent = { Text(text = stringResource(id = CommonStrings.action_delete_poll)) },
style = ListItemStyle.Destructive,
onClick = { state.eventSink(CreatePollEvents.Delete(confirmed = false)) },
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CreatePollTopAppBar(
mode: CreatePollState.Mode,
saveEnabled: Boolean,
onBackClick: () -> Unit = {},
onSaveClick: () -> Unit = {},
) {
TopAppBar(
titleStr = when (mode) {
CreatePollState.Mode.New -> stringResource(id = R.string.screen_create_poll_title)
CreatePollState.Mode.Edit -> stringResource(id = R.string.screen_edit_poll_title)
},
navigationIcon = {
BackButton(onClick = onBackClick)
},
actions = {
TextButton(
text = when (mode) {
CreatePollState.Mode.New -> stringResource(id = CommonStrings.action_create)
CreatePollState.Mode.Edit -> stringResource(id = CommonStrings.action_done)
},
onClick = onSaveClick,
enabled = saveEnabled,
)
}
)
}
@PreviewsDayNight
@Composable
internal fun CreatePollViewPreview(
@PreviewParameter(CreatePollStateProvider::class) state: CreatePollState
) = ElementPreview {
CreatePollView(
state = state,
)
}
private val keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Next,
)
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.create
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
class DefaultCreatePollEntryPoint : CreatePollEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: CreatePollEntryPoint.Params,
): Node {
return parentNode.createNode<CreatePollNode>(
buildContext = buildContext,
plugins = listOf(CreatePollNode.Inputs(timelineMode = params.timelineMode, mode = params.mode))
)
}
}
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.create
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.mapSaver
import io.element.android.features.poll.impl.PollConstants
import io.element.android.features.poll.impl.PollConstants.MIN_ANSWERS
import io.element.android.libraries.matrix.api.poll.PollKind
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
/**
* Represents the state of the poll creation / edit form.
*
* Save this state using [pollFormStateSaver].
*/
data class PollFormState(
val question: String,
val answers: ImmutableList<String>,
val isDisclosed: Boolean,
) {
companion object {
val Empty = PollFormState(
question = "",
answers = MutableList(MIN_ANSWERS) { "" }.toImmutableList(),
isDisclosed = true,
)
}
val pollKind
get() = when (isDisclosed) {
true -> PollKind.Disclosed
false -> PollKind.Undisclosed
}
/**
* Create a copy of the [PollFormState] with a new blank answer added.
*
* If the maximum number of answers has already been reached an answer is not added.
*/
fun withNewAnswer(): PollFormState {
if (!canAddAnswer) {
return this
}
return copy(answers = (answers + "").toImmutableList())
}
/**
* Create a copy of the [PollFormState] with the answer at [index] removed.
*
* If the answer doesn't exist or can't be removed, the state is unchanged.
*
* @param index the index of the answer to remove.
*
* @return a new [PollFormState] with the answer at [index] removed.
*/
fun withAnswerRemoved(index: Int): PollFormState {
if (!canDeleteAnswer) {
return this
}
return copy(answers = answers.filterIndexed { i, _ -> i != index }.toImmutableList())
}
/**
* Create a copy of the [PollFormState] with the answer at [index] changed.
*
* If the new answer is longer than [PollConstants.MAX_ANSWER_LENGTH], it will be truncated.
*
* @param index the index of the answer to change.
* @param rawAnswer the new answer as the user typed it.
*
* @return a new [PollFormState] with the answer at [index] changed.
*/
fun withAnswerChanged(index: Int, rawAnswer: String): PollFormState =
copy(answers = answers.toMutableList().apply {
this[index] = rawAnswer.take(PollConstants.MAX_ANSWER_LENGTH)
}.toImmutableList())
/**
* Whether a new answer can be added.
*/
val canAddAnswer get() = answers.size < PollConstants.MAX_ANSWERS
/**
* Whether any answer can be deleted.
*/
val canDeleteAnswer get() = answers.size > MIN_ANSWERS
/**
* Whether the form is currently valid.
*/
val isValid get() = question.isNotBlank() && answers.size >= MIN_ANSWERS && answers.all { it.isNotBlank() }
}
/**
* A [Saver] for [PollFormState].
*/
internal val pollFormStateSaver = mapSaver(
save = {
mutableMapOf(
"question" to it.question,
"answers" to it.answers.toTypedArray(),
"isDisclosed" to it.isDisclosed,
)
},
restore = { saved ->
PollFormState(
question = saved["question"] as String,
answers = (saved["answers"] as Array<*>).map { it as String }.toImmutableList(),
isDisclosed = saved["isDisclosed"] as Boolean,
)
}
)
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.data
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.runCatchingExceptions
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.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineProvider
import io.element.android.libraries.matrix.api.timeline.getActiveTimeline
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
@AssistedInject
class PollRepository(
private val room: JoinedRoom,
private val defaultTimelineProvider: TimelineProvider,
@Assisted private val timelineMode: Timeline.Mode,
) {
@AssistedFactory
fun interface Factory {
fun create(
timelineMode: Timeline.Mode,
): PollRepository
}
suspend fun getPoll(eventId: EventId): Result<PollContent> = runCatchingExceptions {
getTimelineProvider()
.getOrThrow()
.getActiveTimeline()
.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 -> getTimelineProvider().flatMap { timelineProvider ->
timelineProvider
.getActiveTimeline()
.createPoll(
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
}
else -> getTimelineProvider().flatMap { timelineProvider ->
timelineProvider.getActiveTimeline()
.editPoll(
pollStartId = existingPollId,
question = question,
answers = answers,
maxSelections = maxSelections,
pollKind = pollKind,
)
}
}
suspend fun deletePoll(
pollStartId: EventId,
): Result<Unit> =
getTimelineProvider().flatMap { timelineProvider ->
timelineProvider.getActiveTimeline()
.redactEvent(
eventOrTransactionId = pollStartId.toEventOrTransactionId(),
reason = null,
)
}
private suspend fun getTimelineProvider(): Result<TimelineProvider> {
return when (timelineMode) {
is Timeline.Mode.Thread -> {
val threadedTimelineResult = room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
threadedTimelineResult.map { threadedTimeline ->
object : TimelineProvider {
private val flow = MutableStateFlow<Timeline?>(threadedTimeline)
override fun activeTimelineFlow(): StateFlow<Timeline?> = flow
}
}
}
else -> Result.success(defaultTimelineProvider)
}
}
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.poll.api.history.PollHistoryEntryPoint
import io.element.android.libraries.architecture.createNode
@ContributesBinding(AppScope::class)
class DefaultPollHistoryEntryPoint : PollHistoryEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<PollHistoryFlowNode>(buildContext)
}
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.libraries.matrix.api.core.EventId
sealed interface PollHistoryEvents {
data object LoadMore : PollHistoryEvents
data class SelectPollAnswer(val pollStartId: EventId, val answerId: String) : PollHistoryEvents
data class EndPoll(val pollStartId: EventId) : PollHistoryEvents
data class SelectFilter(val filter: PollHistoryFilter) : PollHistoryEvents
}
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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 com.bumble.appyx.navmodel.backstack.operation.push
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class)
@AssistedInject
class PollHistoryFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val createPollEntryPoint: CreatePollEntryPoint,
) : BaseFlowNode<PollHistoryFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data class EditPoll(val pollStartEventId: EventId) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
is NavTarget.EditPoll -> {
createPollEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = CreatePollEntryPoint.Params(
timelineMode = Timeline.Mode.Live,
mode = CreatePollMode.EditPoll(eventId = navTarget.pollStartEventId)
)
)
}
NavTarget.Root -> {
val callback = object : PollHistoryNode.Callback {
override fun navigateToEditPoll(pollStartEventId: EventId) {
backstack.push(NavTarget.EditPoll(pollStartEventId))
}
}
createNode<PollHistoryNode>(
buildContext = buildContext,
plugins = listOf(callback)
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackView()
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
@ContributesNode(RoomScope::class)
@AssistedInject
class PollHistoryNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: PollHistoryPresenter,
) : Node(
buildContext = buildContext,
plugins = plugins,
) {
interface Callback : Plugin {
fun navigateToEditPoll(pollStartEventId: EventId)
}
private val callback: Callback = callback()
@Composable
override fun View(modifier: Modifier) {
PollHistoryView(
state = presenter.present(),
modifier = modifier,
onEditPoll = callback::navigateToEditPoll,
goBack = this::navigateUp,
)
}
}
@@ -0,0 +1,99 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.rememberSaveable
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItems
import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@Inject
class PollHistoryPresenter(
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val sendPollResponseAction: SendPollResponseAction,
private val endPollAction: EndPollAction,
private val pollHistoryItemFactory: PollHistoryItemsFactory,
private val room: JoinedRoom,
) : Presenter<PollHistoryState> {
@Composable
override fun present(): PollHistoryState {
val timeline = room.liveTimeline
val paginationState by timeline.backwardPaginationStatus.collectAsState()
val pollHistoryItemsFlow = remember {
timeline.timelineItems.map { items ->
pollHistoryItemFactory.create(items)
}
}
var activeFilter by rememberSaveable {
mutableStateOf(PollHistoryFilter.ONGOING)
}
val pollHistoryItems by pollHistoryItemsFlow.collectAsState(initial = PollHistoryItems())
LaunchedEffect(paginationState, pollHistoryItems.size) {
if (pollHistoryItems.size == 0 && paginationState.canPaginate) loadMore(timeline)
}
val isLoading by remember {
derivedStateOf {
pollHistoryItems.size == 0 || paginationState.isPaginating
}
}
val coroutineScope = rememberCoroutineScope()
fun handleEvent(event: PollHistoryEvents) {
when (event) {
is PollHistoryEvents.LoadMore -> {
coroutineScope.loadMore(timeline)
}
is PollHistoryEvents.SelectPollAnswer -> sessionCoroutineScope.launch {
sendPollResponseAction.execute(
timeline = timeline,
pollStartId = event.pollStartId,
answerId = event.answerId
)
}
is PollHistoryEvents.EndPoll -> sessionCoroutineScope.launch {
endPollAction.execute(timeline = timeline, pollStartId = event.pollStartId)
}
is PollHistoryEvents.SelectFilter -> {
activeFilter = event.filter
}
}
}
return PollHistoryState(
isLoading = isLoading,
hasMoreToLoad = paginationState.hasMoreToLoad,
pollHistoryItems = pollHistoryItems,
activeFilter = activeFilter,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.loadMore(pollHistory: Timeline) = launch {
pollHistory.paginate(Timeline.PaginationDirection.BACKWARDS)
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItem
import io.element.android.features.poll.impl.history.model.PollHistoryItems
import kotlinx.collections.immutable.ImmutableList
data class PollHistoryState(
val isLoading: Boolean,
val hasMoreToLoad: Boolean,
val activeFilter: PollHistoryFilter,
val pollHistoryItems: PollHistoryItems,
val eventSink: (PollHistoryEvents) -> Unit,
) {
fun pollHistoryForFilter(filter: PollHistoryFilter): ImmutableList<PollHistoryItem> {
return when (filter) {
PollHistoryFilter.ONGOING -> pollHistoryItems.ongoing
PollHistoryFilter.PAST -> pollHistoryItems.past
}
}
}
@@ -0,0 +1,69 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.poll.api.pollcontent.PollContentState
import io.element.android.features.poll.api.pollcontent.aPollContentState
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItem
import io.element.android.features.poll.impl.history.model.PollHistoryItems
import kotlinx.collections.immutable.toImmutableList
class PollHistoryStateProvider : PreviewParameterProvider<PollHistoryState> {
override val values: Sequence<PollHistoryState>
get() = sequenceOf(
aPollHistoryState(),
aPollHistoryState(
isLoading = true,
hasMoreToLoad = true,
activeFilter = PollHistoryFilter.PAST,
),
aPollHistoryState(
activeFilter = PollHistoryFilter.ONGOING,
currentItems = emptyList(),
),
aPollHistoryState(
activeFilter = PollHistoryFilter.PAST,
currentItems = emptyList(),
),
aPollHistoryState(
activeFilter = PollHistoryFilter.PAST,
currentItems = emptyList(),
hasMoreToLoad = true,
),
)
}
internal fun aPollHistoryState(
isLoading: Boolean = false,
hasMoreToLoad: Boolean = false,
activeFilter: PollHistoryFilter = PollHistoryFilter.ONGOING,
currentItems: List<PollHistoryItem> = listOf(
aPollHistoryItem(),
),
eventSink: (PollHistoryEvents) -> Unit = {},
) = PollHistoryState(
isLoading = isLoading,
hasMoreToLoad = hasMoreToLoad,
activeFilter = activeFilter,
pollHistoryItems = PollHistoryItems(
ongoing = currentItems.toImmutableList(),
past = currentItems.toImmutableList(),
),
eventSink = eventSink,
)
internal fun aPollHistoryItem(
formattedDate: String = "01/12/2023",
state: PollContentState = aPollContentState(),
) = PollHistoryItem(
formattedDate = formattedDate,
state = state,
)
@@ -0,0 +1,265 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.poll.api.pollcontent.PollContentView
import io.element.android.features.poll.impl.R
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItem
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PollHistoryView(
state: PollHistoryState,
onEditPoll: (EventId) -> Unit,
goBack: () -> Unit,
modifier: Modifier = Modifier,
) {
fun onLoadMore() {
state.eventSink(PollHistoryEvents.LoadMore)
}
fun onSelectAnswer(pollStartId: EventId, answerId: String) {
state.eventSink(PollHistoryEvents.SelectPollAnswer(pollStartId, answerId))
}
fun onEndPoll(pollStartId: EventId) {
state.eventSink(PollHistoryEvents.EndPoll(pollStartId))
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
titleStr = stringResource(R.string.screen_polls_history_title),
navigationIcon = {
BackButton(onClick = goBack)
},
)
},
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
) {
val pagerState = rememberPagerState(state.activeFilter.ordinal, 0f) {
PollHistoryFilter.entries.size
}
LaunchedEffect(state.activeFilter) {
pagerState.scrollToPage(state.activeFilter.ordinal)
}
PollHistoryFilterButtons(
activeFilter = state.activeFilter,
onSelectFilter = { state.eventSink(PollHistoryEvents.SelectFilter(it)) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
HorizontalPager(
state = pagerState,
userScrollEnabled = false,
modifier = Modifier.fillMaxSize()
) { page ->
val filter = PollHistoryFilter.entries[page]
val pollHistoryItems = state.pollHistoryForFilter(filter)
PollHistoryList(
filter = filter,
pollHistoryItems = pollHistoryItems,
hasMoreToLoad = state.hasMoreToLoad,
isLoading = state.isLoading,
onSelectAnswer = ::onSelectAnswer,
onEditPoll = onEditPoll,
onEndPoll = ::onEndPoll,
onLoadMore = ::onLoadMore,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PollHistoryFilterButtons(
activeFilter: PollHistoryFilter,
onSelectFilter: (PollHistoryFilter) -> Unit,
modifier: Modifier = Modifier,
) {
SingleChoiceSegmentedButtonRow(modifier = modifier) {
PollHistoryFilter.entries.forEach { filter ->
SegmentedButton(
index = filter.ordinal,
count = PollHistoryFilter.entries.size,
selected = activeFilter == filter,
onClick = { onSelectFilter(filter) },
text = stringResource(filter.stringResource),
)
}
}
}
@Composable
private fun PollHistoryList(
filter: PollHistoryFilter,
pollHistoryItems: ImmutableList<PollHistoryItem>,
hasMoreToLoad: Boolean,
isLoading: Boolean,
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
onEditPoll: (pollStartId: EventId) -> Unit,
onEndPoll: (pollStartId: EventId) -> Unit,
onLoadMore: () -> Unit,
modifier: Modifier = Modifier,
) {
val lazyListState = rememberLazyListState()
LazyColumn(
state = lazyListState,
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(pollHistoryItems) { pollHistoryItem ->
PollHistoryItemRow(
pollHistoryItem = pollHistoryItem,
onSelectAnswer = onSelectAnswer,
onEditPoll = onEditPoll,
onEndPoll = onEndPoll,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp)
)
}
if (pollHistoryItems.isEmpty()) {
item {
Column(
modifier = Modifier
.fillParentMaxSize()
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
val emptyStringResource = if (filter == PollHistoryFilter.PAST) {
stringResource(R.string.screen_polls_history_empty_past)
} else {
stringResource(R.string.screen_polls_history_empty_ongoing)
}
Text(
text = emptyStringResource,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp, horizontal = 16.dp),
textAlign = TextAlign.Center,
)
if (hasMoreToLoad) {
LoadMoreButton(isLoading = isLoading, onClick = onLoadMore)
}
}
}
} else if (hasMoreToLoad) {
item {
LoadMoreButton(isLoading = isLoading, onClick = onLoadMore)
}
}
}
}
@Composable
private fun LoadMoreButton(isLoading: Boolean, onClick: () -> Unit) {
Button(
text = stringResource(CommonStrings.action_load_more),
showProgress = isLoading,
onClick = onClick,
modifier = Modifier.padding(vertical = 24.dp),
)
}
@Composable
private fun PollHistoryItemRow(
pollHistoryItem: PollHistoryItem,
onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit,
onEditPoll: (pollStartId: EventId) -> Unit,
onEndPoll: (pollStartId: EventId) -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier.semantics(mergeDescendants = true) {
// Allow the answers to be traversed by Talkback
isTraversalGroup = true
},
border = BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary),
shape = RoundedCornerShape(size = 12.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = pollHistoryItem.formattedDate,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
)
Spacer(modifier = Modifier.height(4.dp))
PollContentView(
state = pollHistoryItem.state,
onSelectAnswer = onSelectAnswer,
onEditPoll = onEditPoll,
onEndPoll = onEndPoll,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun PollHistoryViewPreview(
@PreviewParameter(PollHistoryStateProvider::class) state: PollHistoryState
) = ElementPreview {
PollHistoryView(
state = state,
onEditPoll = {},
goBack = {},
)
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history.model
import io.element.android.features.poll.impl.R
enum class PollHistoryFilter(val stringResource: Int) {
ONGOING(R.string.screen_polls_history_filter_ongoing),
PAST(R.string.screen_polls_history_filter_past),
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history.model
import io.element.android.features.poll.api.pollcontent.PollContentState
data class PollHistoryItem(
val formattedDate: String,
val state: PollContentState,
)
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history.model
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
data class PollHistoryItems(
val ongoing: ImmutableList<PollHistoryItem> = persistentListOf(),
val past: ImmutableList<PollHistoryItem> = persistentListOf(),
) {
val size = ongoing.size + past.size
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history.model
import dev.zacsweers.metro.Inject
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.withContext
@Inject
class PollHistoryItemsFactory(
private val pollContentStateFactory: PollContentStateFactory,
private val dateFormatter: DateFormatter,
private val dispatchers: CoroutineDispatchers,
) {
suspend fun create(timelineItems: List<MatrixTimelineItem>): PollHistoryItems = withContext(dispatchers.computation) {
val past = ArrayList<PollHistoryItem>()
val ongoing = ArrayList<PollHistoryItem>()
for (index in timelineItems.indices.reversed()) {
val timelineItem = timelineItems[index]
val pollHistoryItem = create(timelineItem) ?: continue
if (pollHistoryItem.state.isPollEnded) {
past.add(pollHistoryItem)
} else {
ongoing.add(pollHistoryItem)
}
}
PollHistoryItems(
ongoing = ongoing.toImmutableList(),
past = past.toImmutableList()
)
}
private suspend fun create(timelineItem: MatrixTimelineItem): PollHistoryItem? {
return when (timelineItem) {
is MatrixTimelineItem.Event -> {
val pollContent = timelineItem.event.content as? PollContent ?: return null
val pollContentState = pollContentStateFactory.create(
eventId = timelineItem.eventId,
isEditable = timelineItem.event.isEditable,
isOwn = timelineItem.event.isOwn,
content = pollContent,
)
PollHistoryItem(
formattedDate = dateFormatter.format(
timestamp = timelineItem.event.timestamp,
mode = DateFormatterMode.Day,
useRelative = true
),
state = pollContentState
)
}
else -> null
}
}
}
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.model
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.poll.api.pollcontent.PollAnswerItem
import io.element.android.features.poll.api.pollcontent.PollContentState
import io.element.android.features.poll.api.pollcontent.PollContentStateFactory
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.poll.isDisclosed
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import kotlinx.collections.immutable.toImmutableList
@ContributesBinding(RoomScope::class)
class DefaultPollContentStateFactory(
private val matrixClient: MatrixClient,
) : PollContentStateFactory {
override suspend fun create(
eventId: EventId?,
isEditable: Boolean,
isOwn: Boolean,
content: PollContent,
): PollContentState {
val totalVoteCount = content.votes.flatMap { it.value }.size
val myVotes = content.votes.filter { matrixClient.sessionId in it.value }.keys
val isPollEnded = content.endTime != null
val winnerIds = if (!isPollEnded) {
emptyList()
} else {
content.answers
.map { answer -> answer.id }
.groupBy { answerId -> content.votes[answerId]?.size ?: 0 } // Group by votes count
.maxByOrNull { (votes, _) -> votes } // Keep max voted answers
?.takeIf { (votes, _) -> votes > 0 } // Ignore if no option has been voted
?.value
.orEmpty()
}
val answerItems = content.answers.map { answer ->
val answerVoteCount = content.votes[answer.id]?.size ?: 0
val isSelected = answer.id in myVotes
val isWinner = answer.id in winnerIds
val percentage = if (totalVoteCount > 0) answerVoteCount.toFloat() / totalVoteCount.toFloat() else 0f
PollAnswerItem(
answer = answer,
isSelected = isSelected,
isEnabled = !isPollEnded,
isWinner = isWinner,
showVotes = content.kind.isDisclosed || isPollEnded,
votesCount = answerVoteCount,
percentage = percentage,
)
}
return PollContentState(
eventId = eventId,
question = content.question,
answerItems = answerItems.toImmutableList(),
pollKind = content.kind,
isPollEditable = isEditable,
isPollEnded = isPollEnded,
isMine = isOwn,
)
}
}
@@ -0,0 +1,19 @@
<?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">"Дадаць варыянт"</string>
<string name="screen_create_poll_anonymous_desc">"Паказаць вынікі толькі пасля заканчэння апытання"</string>
<string name="screen_create_poll_anonymous_headline">"Схаваць галасы"</string>
<string name="screen_create_poll_answer_hint">"Варыянт %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Вашы змены не былі захаваны. Вы ўпэўнены, што хочаце вярнуцца?"</string>
<string name="screen_create_poll_question_desc">"Пытанне або тэма"</string>
<string name="screen_create_poll_question_hint">"Пра што апытанне?"</string>
<string name="screen_create_poll_title">"Стварэнне апытання"</string>
<string name="screen_edit_poll_delete_confirmation">"Вы ўпэўнены, што хочаце выдаліць гэтае апытанне?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Выдаліць апытанне"</string>
<string name="screen_edit_poll_title">"Рэдагаваць апытанне"</string>
<string name="screen_polls_history_empty_ongoing">"Немагчыма знайсці бягучыя апытанні."</string>
<string name="screen_polls_history_empty_past">"Немагчыма знайсці мінулыя апытанні."</string>
<string name="screen_polls_history_filter_ongoing">"Бягучыя"</string>
<string name="screen_polls_history_filter_past">"Мінулыя"</string>
<string name="screen_polls_history_title">"Апытанні"</string>
</resources>
@@ -0,0 +1,18 @@
<?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">"Добавяне на опция"</string>
<string name="screen_create_poll_anonymous_desc">"Показване на резултатите само след приключване на анкетата"</string>
<string name="screen_create_poll_anonymous_headline">"Скриване на гласовете"</string>
<string name="screen_create_poll_answer_hint">"Опция %1$d"</string>
<string name="screen_create_poll_question_desc">"Въпрос или тема"</string>
<string name="screen_create_poll_question_hint">"За какво се отнася анкетата?"</string>
<string name="screen_create_poll_title">"Създаване на анкета"</string>
<string name="screen_edit_poll_delete_confirmation">"Сигурни ли сте, че искате да изтриете тази анкета?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Изтриване на анкетата"</string>
<string name="screen_edit_poll_title">"Редактиране на анкетата"</string>
<string name="screen_polls_history_empty_ongoing">"Не се намират текущи анкети."</string>
<string name="screen_polls_history_empty_past">"Не се намират приключили анкети."</string>
<string name="screen_polls_history_filter_ongoing">"Текущи"</string>
<string name="screen_polls_history_filter_past">"Приключили"</string>
<string name="screen_polls_history_title">"Анкети"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Přidat volbu"</string>
<string name="screen_create_poll_anonymous_desc">"Zobrazit výsledky až po skončení hlasování"</string>
<string name="screen_create_poll_anonymous_headline">"Anonymní hlasování"</string>
<string name="screen_create_poll_answer_hint">"Volba %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Vaše změny nebyly uloženy. Opravdu se chcete vrátit?"</string>
<string name="screen_create_poll_delete_option_a11y">"Smazat možnost %1$s"</string>
<string name="screen_create_poll_question_desc">"Otázka nebo téma"</string>
<string name="screen_create_poll_question_hint">"Čeho se hlasování týká?"</string>
<string name="screen_create_poll_title">"Vytvořit hlasování"</string>
<string name="screen_edit_poll_delete_confirmation">"Opravdu chcete odstranit toto hlasování?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Odstranit hlasování"</string>
<string name="screen_edit_poll_title">"Upravit hlasování"</string>
<string name="screen_polls_history_empty_ongoing">"Nelze najít žádná probíhající hlasování."</string>
<string name="screen_polls_history_empty_past">"Nelze najít žádná minulá hlasování."</string>
<string name="screen_polls_history_filter_ongoing">"Probíhající"</string>
<string name="screen_polls_history_filter_past">"Minulé"</string>
<string name="screen_polls_history_title">"Hlasování"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Ychwanegu dewis"</string>
<string name="screen_create_poll_anonymous_desc">"Dangos canlyniadau dim ond ar ôl i\'r pleidleisio ddod i ben"</string>
<string name="screen_create_poll_anonymous_headline">"Cuddio pleidleisiau"</string>
<string name="screen_create_poll_answer_hint">"Dewis %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Dyw eich newidiadau heb gael eu cadw. Ydych chi\'n siŵr eich bod am fynd nôl?"</string>
<string name="screen_create_poll_delete_option_a11y">"Dileu opsiwn %1$s"</string>
<string name="screen_create_poll_question_desc">"Cwestiwn neu bwnc"</string>
<string name="screen_create_poll_question_hint">"Am beth mae\'r bleidlais?"</string>
<string name="screen_create_poll_title">"Creu Pleidlais"</string>
<string name="screen_edit_poll_delete_confirmation">"Ydych chi\'n siŵr eich bod am ddileu\'r bleidlais hon?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Dileu Pleidlais"</string>
<string name="screen_edit_poll_title">"Golygu pleidlais"</string>
<string name="screen_polls_history_empty_ongoing">"Methu dod o hyd i unrhyw bleidleisiau cyfredol."</string>
<string name="screen_polls_history_empty_past">"Methu dod o hyd i unrhyw bleidleisiau blaenorol."</string>
<string name="screen_polls_history_filter_ongoing">"Parhaus"</string>
<string name="screen_polls_history_filter_past">"Gorffennol"</string>
<string name="screen_polls_history_title">"Pleidleisiau"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Tilføj mulighed"</string>
<string name="screen_create_poll_anonymous_desc">"Vis først resultater, når afstemningen er slut"</string>
<string name="screen_create_poll_anonymous_headline">"Skjul stemmer"</string>
<string name="screen_create_poll_answer_hint">"Mulighed %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Dine ændringer er ikke blevet gemt. Er du sikker på, at du vil gå tilbage?"</string>
<string name="screen_create_poll_delete_option_a11y">"Slet mulighed %1$s"</string>
<string name="screen_create_poll_question_desc">"Spørgsmål eller emne"</string>
<string name="screen_create_poll_question_hint">"Hvad handler afstemningen om?"</string>
<string name="screen_create_poll_title">"Opret afstemning"</string>
<string name="screen_edit_poll_delete_confirmation">"Er du sikker på, at du ønsker at slette denne afstemning?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Slet afstemning"</string>
<string name="screen_edit_poll_title">"Redigér afstemning"</string>
<string name="screen_polls_history_empty_ongoing">"Kan ikke finde nogen igangværende afstemninger."</string>
<string name="screen_polls_history_empty_past">"Kan ikke finde nogen tidligere afstemninger."</string>
<string name="screen_polls_history_filter_ongoing">"Igangværende"</string>
<string name="screen_polls_history_filter_past">"Tidligere"</string>
<string name="screen_polls_history_title">"Afstemninger"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Option hinzufügen"</string>
<string name="screen_create_poll_anonymous_desc">"Ergebnisse erst nach Ende der Umfrage anzeigen"</string>
<string name="screen_create_poll_anonymous_headline">"Anonyme Umfrage"</string>
<string name="screen_create_poll_answer_hint">"Option %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Deine Änderungen wurden nicht gespeichert. Bist du sicher, dass du zurückgehen willst?"</string>
<string name="screen_create_poll_delete_option_a11y">"Lösche Option %1$s"</string>
<string name="screen_create_poll_question_desc">"Frage oder Thema"</string>
<string name="screen_create_poll_question_hint">"Worum geht es bei der Umfrage?"</string>
<string name="screen_create_poll_title">"Umfrage erstellen"</string>
<string name="screen_edit_poll_delete_confirmation">"Möchtest du diese Umfrage wirklich löschen?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Umfrage löschen"</string>
<string name="screen_edit_poll_title">"Umfrage bearbeiten"</string>
<string name="screen_polls_history_empty_ongoing">"Keine laufenden Umfragen vorhanden."</string>
<string name="screen_polls_history_empty_past">"Keine beendeten Umfragen vorhanden."</string>
<string name="screen_polls_history_filter_ongoing">"Laufend"</string>
<string name="screen_polls_history_filter_past">"Beendet"</string>
<string name="screen_polls_history_title">"Umfragen"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Προσθήκη επιλογής"</string>
<string name="screen_create_poll_anonymous_desc">"Εμφάνιση αποτελεσμάτων μόνο μετά τη λήξη της ψηφοφορίας"</string>
<string name="screen_create_poll_anonymous_headline">"Απόκρυψη ψήφων"</string>
<string name="screen_create_poll_answer_hint">"Επιλογή %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Οι αλλαγές σου δεν έχουν αποθηκευτεί. Σίγουρα θες να πας πίσω;"</string>
<string name="screen_create_poll_delete_option_a11y">"Διαγραφή επιλογής %1$s"</string>
<string name="screen_create_poll_question_desc">"Ερώτηση ή θέμα"</string>
<string name="screen_create_poll_question_hint">"Τί αφορά η δημοσκόπηση;"</string>
<string name="screen_create_poll_title">"Δημιουργία Δημοσκόπησης"</string>
<string name="screen_edit_poll_delete_confirmation">"Θες σίγουρα να διαγράψεις αυτήν τη δημοσκόπηση;"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Διαγραφή Δημοσκόπησης"</string>
<string name="screen_edit_poll_title">"Επεξεργασία δημοσκόπησης"</string>
<string name="screen_polls_history_empty_ongoing">"Δεν είναι δυνατή η εύρεση ενεργών δημοσκοπήσεων"</string>
<string name="screen_polls_history_empty_past">"Δεν είναι δυνατή η εύρεση παλιών δημοσκοπήσεων"</string>
<string name="screen_polls_history_filter_ongoing">"Σε εξέλιξη"</string>
<string name="screen_polls_history_filter_past">"Παρελθόν"</string>
<string name="screen_polls_history_title">"Δημοσκοπήσεις"</string>
</resources>
@@ -0,0 +1,19 @@
<?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">"Añadir opción"</string>
<string name="screen_create_poll_anonymous_desc">"Mostrar los resultados solo después de que finalice la encuesta"</string>
<string name="screen_create_poll_anonymous_headline">"Ocultar votos"</string>
<string name="screen_create_poll_answer_hint">"Opción %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Tus cambios no se han guardado. ¿Estás seguro de que quieres volver atrás?"</string>
<string name="screen_create_poll_question_desc">"Pregunta o tema"</string>
<string name="screen_create_poll_question_hint">"¿De qué trata la encuesta?"</string>
<string name="screen_create_poll_title">"Crear una Encuesta"</string>
<string name="screen_edit_poll_delete_confirmation">"¿Seguro que quieres eliminar esta encuesta?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Eliminar encuesta"</string>
<string name="screen_edit_poll_title">"Editar encuesta"</string>
<string name="screen_polls_history_empty_ongoing">"No se pudo encontrar ninguna encuesta en curso."</string>
<string name="screen_polls_history_empty_past">"No se pudo encontrar ninguna encuesta anterior."</string>
<string name="screen_polls_history_filter_ongoing">"En curso"</string>
<string name="screen_polls_history_filter_past">"Anteriores"</string>
<string name="screen_polls_history_title">"Encuestas"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Lisa veel üks valik"</string>
<string name="screen_create_poll_anonymous_desc">"Näita tulemusi alles pärast küsitluse lõppu"</string>
<string name="screen_create_poll_anonymous_headline">"Peida hääled"</string>
<string name="screen_create_poll_answer_hint">"Valik %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Sinu tehtud muudatused pole veel salvestatud. Kas sa oled kindel, et soovid minna tagasi?"</string>
<string name="screen_create_poll_delete_option_a11y">"Kustuta valik: %1$s"</string>
<string name="screen_create_poll_question_desc">"Küsimus või teema"</string>
<string name="screen_create_poll_question_hint">"Mis on küsitluse teema?"</string>
<string name="screen_create_poll_title">"Loo küsitlus"</string>
<string name="screen_edit_poll_delete_confirmation">"Kas sa oled kindel, et soovid selle küsitluse kustutada?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Kustuta küsitlus"</string>
<string name="screen_edit_poll_title">"Muuda küsitlust"</string>
<string name="screen_polls_history_empty_ongoing">"Ei leia ühtegi käimasolevat küsitlust."</string>
<string name="screen_polls_history_empty_past">"Ei leia ühtegi varasemat küsitlust."</string>
<string name="screen_polls_history_filter_ongoing">"Käimasolev küsitlus"</string>
<string name="screen_polls_history_filter_past">"Varasem küsitlus"</string>
<string name="screen_polls_history_title">"Küsitlused"</string>
</resources>
@@ -0,0 +1,19 @@
<?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">"Gehitu aukera"</string>
<string name="screen_create_poll_anonymous_desc">"Erakutsi emaitzak inkesta amaitutakoan soilik"</string>
<string name="screen_create_poll_anonymous_headline">"Ezkutatu botoak"</string>
<string name="screen_create_poll_answer_hint">"%1$d. aukera"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Zure aldaketak ez dira gorde. Ziur itzuli nahi duzula?"</string>
<string name="screen_create_poll_question_desc">"Galdera edo gaia"</string>
<string name="screen_create_poll_question_hint">"Zeri buruzko inkesta da?"</string>
<string name="screen_create_poll_title">"Sortu inkesta"</string>
<string name="screen_edit_poll_delete_confirmation">"Ziur inkesta hau ezabatu nahi duzula?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Ezabatu inkesta"</string>
<string name="screen_edit_poll_title">"Editatu inkesta"</string>
<string name="screen_polls_history_empty_ongoing">"Ezin da abian dagoen inkestarik aurkitu."</string>
<string name="screen_polls_history_empty_past">"Ezin da iraungitako inkestarik aurkitu."</string>
<string name="screen_polls_history_filter_ongoing">"Abian direnak"</string>
<string name="screen_polls_history_filter_past">"Iraungitakoak"</string>
<string name="screen_polls_history_title">"Inkestak"</string>
</resources>
@@ -0,0 +1,19 @@
<?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">"افزودن گزینه"</string>
<string name="screen_create_poll_anonymous_desc">"نمایش نتیجه‌ها تنها پس از پایان نظرسنجی"</string>
<string name="screen_create_poll_anonymous_headline">"نهفتن رأی‌ها"</string>
<string name="screen_create_poll_answer_hint">"گزینهٔ %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"تغییراتتان ذخیره نشده‌اند. مطمئنید که می‌خواهید برگردید؟"</string>
<string name="screen_create_poll_question_desc">"پرسش یا موضوع"</string>
<string name="screen_create_poll_question_hint">"این نظرسنجی دربارهٔ چیست؟"</string>
<string name="screen_create_poll_title">"ایجاد نظرسنجی"</string>
<string name="screen_edit_poll_delete_confirmation">"مطمئنید که می‌خواهید این نظرسنجی را حذف کنید؟"</string>
<string name="screen_edit_poll_delete_confirmation_title">"حذف نظرسنجی"</string>
<string name="screen_edit_poll_title">"ویرایش نظرسنجی"</string>
<string name="screen_polls_history_empty_ongoing">"نتوانست هیچ نظرسنجی در جریانی بیابد."</string>
<string name="screen_polls_history_empty_past">"نتوانست هیچ نظرسنجی گذشته‌ای بیابد."</string>
<string name="screen_polls_history_filter_ongoing">"در جریان"</string>
<string name="screen_polls_history_filter_past">"گذشته"</string>
<string name="screen_polls_history_title">"نظرسنجی‌ها"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Lisää vaihtoehto"</string>
<string name="screen_create_poll_anonymous_desc">"Näytä tulokset vasta kyselyn päätyttyä"</string>
<string name="screen_create_poll_anonymous_headline">"Piilota äänet"</string>
<string name="screen_create_poll_answer_hint">"Vaihtoehto %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Muutoksiasi ei ole tallennettu. Haluatko varmasti palata takaisin?"</string>
<string name="screen_create_poll_delete_option_a11y">"Poista vaihtoehto %1$s"</string>
<string name="screen_create_poll_question_desc">"Kysymys tai aihe"</string>
<string name="screen_create_poll_question_hint">"Mistä kyselyssä on kyse?"</string>
<string name="screen_create_poll_title">"Luo kysely"</string>
<string name="screen_edit_poll_delete_confirmation">"Haluatko varmasti poistaa tämän kyselyn?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Poista kysely"</string>
<string name="screen_edit_poll_title">"Muokkaa kyselyä"</string>
<string name="screen_polls_history_empty_ongoing">"Meneillään olevia kyselyjä ei löytynyt."</string>
<string name="screen_polls_history_empty_past">"Aiempia kyselyjä ei löytynyt."</string>
<string name="screen_polls_history_filter_ongoing">"Meneillään olevat"</string>
<string name="screen_polls_history_filter_past">"Aiemmat"</string>
<string name="screen_polls_history_title">"Kyselyt"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Ajouter une option"</string>
<string name="screen_create_poll_anonymous_desc">"Afficher les résultats uniquement après la fin du sondage"</string>
<string name="screen_create_poll_anonymous_headline">"Masquer les votes"</string>
<string name="screen_create_poll_answer_hint">"Option %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Vos modifications nont pas été enregistrées. Êtes-vous certain de vouloir quitter ?"</string>
<string name="screen_create_poll_delete_option_a11y">"Supprimer loption %1$s"</string>
<string name="screen_create_poll_question_desc">"Question ou sujet"</string>
<string name="screen_create_poll_question_hint">"Quel est le sujet du sondage ?"</string>
<string name="screen_create_poll_title">"Créer un sondage"</string>
<string name="screen_edit_poll_delete_confirmation">"Êtes-vous certain de vouloir supprimer ce sondage ?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Supprimer le sondage"</string>
<string name="screen_edit_poll_title">"Modifier le sondage"</string>
<string name="screen_polls_history_empty_ongoing">"Impossible de trouver des sondages en cours."</string>
<string name="screen_polls_history_empty_past">"Impossible de trouver des sondages terminés."</string>
<string name="screen_polls_history_filter_ongoing">"En cours"</string>
<string name="screen_polls_history_filter_past">"Terminés"</string>
<string name="screen_polls_history_title">"Sondages"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Lehetőség hozzáadása"</string>
<string name="screen_create_poll_anonymous_desc">"Eredmények megjelenítése csak a szavazás befejezése után"</string>
<string name="screen_create_poll_anonymous_headline">"Szavazatok elrejtése"</string>
<string name="screen_create_poll_answer_hint">"%1$d. lehetőség"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"A módosítások nem lettek mentve. Biztos, hogy visszalép?"</string>
<string name="screen_create_poll_delete_option_a11y">"Lehetőség törlése: %1$s"</string>
<string name="screen_create_poll_question_desc">"Kérdés vagy téma"</string>
<string name="screen_create_poll_question_hint">"Miről szól ez a szavazás?"</string>
<string name="screen_create_poll_title">"Szavazás létrehozása"</string>
<string name="screen_edit_poll_delete_confirmation">"Biztos, hogy törli ezt a szavazást?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Szavazás törlése"</string>
<string name="screen_edit_poll_title">"Szavazás szerkesztése"</string>
<string name="screen_polls_history_empty_ongoing">"Nem találhatók folyamatban lévő szavazások."</string>
<string name="screen_polls_history_empty_past">"Nem található korábbi szavazás."</string>
<string name="screen_polls_history_filter_ongoing">"Folyamatban"</string>
<string name="screen_polls_history_filter_past">"Korábbi"</string>
<string name="screen_polls_history_title">"Szavazások"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Tambahkan opsi"</string>
<string name="screen_create_poll_anonymous_desc">"Tampilkan hasil hanya setelah pemungutan suara berakhir"</string>
<string name="screen_create_poll_anonymous_headline">"Pemungutan suara anonim"</string>
<string name="screen_create_poll_answer_hint">"Opsi %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Perubahan Anda belum disimpan. Apakah Anda yakin ingin kembali?"</string>
<string name="screen_create_poll_delete_option_a11y">"Hapus opsi %1$s"</string>
<string name="screen_create_poll_question_desc">"Pertanyaan atau topik"</string>
<string name="screen_create_poll_question_hint">"Tentang apa pemungutan suara ini?"</string>
<string name="screen_create_poll_title">"Buat pemungutan suara"</string>
<string name="screen_edit_poll_delete_confirmation">"Apakah Anda yakin ingin menghapus pemungutan suara ini?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Hapus pemungutan suara"</string>
<string name="screen_edit_poll_title">"Sunting pemungutan suara"</string>
<string name="screen_polls_history_empty_ongoing">"Tidak dapat menemukan pemungutan suara yang sedang berlangsung."</string>
<string name="screen_polls_history_empty_past">"Tidak dapat menemukan pemungutan suara sebelumnya."</string>
<string name="screen_polls_history_filter_ongoing">"Sedang berlangsung"</string>
<string name="screen_polls_history_filter_past">"Masa lalu"</string>
<string name="screen_polls_history_title">"Pemungutan suara"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Aggiungi opzione"</string>
<string name="screen_create_poll_anonymous_desc">"Mostra i risultati solo al termine del sondaggio"</string>
<string name="screen_create_poll_anonymous_headline">"Nascondi voti"</string>
<string name="screen_create_poll_answer_hint">"Opzione %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Le modifiche non sono state salvate. Vuoi davvero tornare indietro?"</string>
<string name="screen_create_poll_delete_option_a11y">"Elimina l\'opzione %1$s"</string>
<string name="screen_create_poll_question_desc">"Domanda o argomento"</string>
<string name="screen_create_poll_question_hint">"Di cosa parla il sondaggio?"</string>
<string name="screen_create_poll_title">"Crea sondaggio"</string>
<string name="screen_edit_poll_delete_confirmation">"Vuoi davvero eliminare questo sondaggio?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Elimina sondaggio"</string>
<string name="screen_edit_poll_title">"Modifica sondaggio"</string>
<string name="screen_polls_history_empty_ongoing">"Impossibile trovare sondaggi in corso."</string>
<string name="screen_polls_history_empty_past">"Impossibile trovare sondaggi passati."</string>
<string name="screen_polls_history_filter_ongoing">"In corso"</string>
<string name="screen_polls_history_filter_past">"Passato"</string>
<string name="screen_polls_history_title">"Sondaggi"</string>
</resources>
@@ -0,0 +1,19 @@
<?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">"ვარიანტის დამატება"</string>
<string name="screen_create_poll_anonymous_desc">"შედეგების ჩვენება მხოლოდ გამოკითხვის დასრულების შემდეგ"</string>
<string name="screen_create_poll_anonymous_headline">"ხმების დამალვა"</string>
<string name="screen_create_poll_answer_hint">"ვარიანტი %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"თქვენი ცვლილებები არაა შენახული. დარწმუნებული ხართ დაბრუნებაში?"</string>
<string name="screen_create_poll_question_desc">"კითხვა ან თემა"</string>
<string name="screen_create_poll_question_hint">"რას ეხება გამოკითხვა?"</string>
<string name="screen_create_poll_title">"გამოკითხვის შექმნა"</string>
<string name="screen_edit_poll_delete_confirmation">"დარწმუნებული ხართ, რომ გსურთ ამ გამოკითხვის წაშლა?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"გამოკითხვის წაშლა"</string>
<string name="screen_edit_poll_title">"გამოკითხვის რედაქტირება"</string>
<string name="screen_polls_history_empty_ongoing">"მიმდინარე გამოკითხვები ვერ მოიძებნა."</string>
<string name="screen_polls_history_empty_past">"ბოლო გამოკითხვების მოძებნა ვერ მოხერხდა."</string>
<string name="screen_polls_history_filter_ongoing">"მიმდინარე"</string>
<string name="screen_polls_history_filter_past">"წარსული"</string>
<string name="screen_polls_history_title">"გამოკითხვები"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"옵션 추가"</string>
<string name="screen_create_poll_anonymous_desc">"투표가 끝난 이후에만 결과 표시"</string>
<string name="screen_create_poll_anonymous_headline">"투표 숨기기"</string>
<string name="screen_create_poll_answer_hint">"옵션 %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"변경 내용이 저장되지 않았습니다. 정말로 돌아가시겠습니까?"</string>
<string name="screen_create_poll_delete_option_a11y">"삭제 옵션 %1$s"</string>
<string name="screen_create_poll_question_desc">"질문 또는 주제"</string>
<string name="screen_create_poll_question_hint">"무슨 투표인가요?"</string>
<string name="screen_create_poll_title">"투표 생성"</string>
<string name="screen_edit_poll_delete_confirmation">"정말 이 투표를 삭제하시겠습니까?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"투표 삭제"</string>
<string name="screen_edit_poll_title">"투표 수정"</string>
<string name="screen_polls_history_empty_ongoing">"진행 중인 투표를 찾을 수 없습니다."</string>
<string name="screen_polls_history_empty_past">"과거의 투표를 찾을 수 없습니다."</string>
<string name="screen_polls_history_filter_ongoing">"진행 중"</string>
<string name="screen_polls_history_filter_past">"과거"</string>
<string name="screen_polls_history_title">"투표"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Legg til alternativ"</string>
<string name="screen_create_poll_anonymous_desc">"Vis resultater bare etter at avstemningen er avsluttet"</string>
<string name="screen_create_poll_anonymous_headline">"Skjul stemmer"</string>
<string name="screen_create_poll_answer_hint">"Alternativ %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Endringene dine er ikke lagret. Er du sikker på at du vil gå tilbake?"</string>
<string name="screen_create_poll_delete_option_a11y">"Slett alternativet %1$s"</string>
<string name="screen_create_poll_question_desc">"Spørsmål eller emne"</string>
<string name="screen_create_poll_question_hint">"Hva handler avstemningen om?"</string>
<string name="screen_create_poll_title">"Opprett avstemning"</string>
<string name="screen_edit_poll_delete_confirmation">"Er du sikker på at du vil slette denne avstemningen?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Slett avstemning"</string>
<string name="screen_edit_poll_title">"Rediger avstemning"</string>
<string name="screen_polls_history_empty_ongoing">"Finner ingen pågående avstemninger."</string>
<string name="screen_polls_history_empty_past">"Kan ikke finne noen tidligere avstemninger."</string>
<string name="screen_polls_history_filter_ongoing">"Pågående"</string>
<string name="screen_polls_history_filter_past">"Fortid"</string>
<string name="screen_polls_history_title">"Avstemninger"</string>
</resources>
@@ -0,0 +1,19 @@
<?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">"Optie toevoegen"</string>
<string name="screen_create_poll_anonymous_desc">"Resultaten pas weergeven nadat de peiling is afgelopen"</string>
<string name="screen_create_poll_anonymous_headline">"Stemmen verbergen"</string>
<string name="screen_create_poll_answer_hint">"Optie %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Je wijzigingen zijn niet opgeslagen. Weet je zeker dat je terug wilt gaan?"</string>
<string name="screen_create_poll_question_desc">"Vraag of onderwerp"</string>
<string name="screen_create_poll_question_hint">"Waar gaat de peiling over?"</string>
<string name="screen_create_poll_title">"Peiling maken"</string>
<string name="screen_edit_poll_delete_confirmation">"Weet je zeker dat je deze peiling wilt verwijderen?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Peiling verwijderen"</string>
<string name="screen_edit_poll_title">"Peiling wijzigen"</string>
<string name="screen_polls_history_empty_ongoing">"Kan geen actieve peilingen vinden."</string>
<string name="screen_polls_history_empty_past">"Kan geen eerdere peilingen vinden."</string>
<string name="screen_polls_history_filter_ongoing">"Actief"</string>
<string name="screen_polls_history_filter_past">"Afgelopen"</string>
<string name="screen_polls_history_title">"Peilingen"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Dodaj opcję"</string>
<string name="screen_create_poll_anonymous_desc">"Pokaż wyniki dopiero po zakończeniu ankiety"</string>
<string name="screen_create_poll_anonymous_headline">"Ukryj głosy"</string>
<string name="screen_create_poll_answer_hint">"Opcja %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Zmiany nie zostały zapisane. Czy na pewno chcesz wrócić?"</string>
<string name="screen_create_poll_delete_option_a11y">"Usuń opcję %1$s"</string>
<string name="screen_create_poll_question_desc">"Pytanie lub temat"</string>
<string name="screen_create_poll_question_hint">"Czego dotyczy ankieta?"</string>
<string name="screen_create_poll_title">"Utwórz ankietę"</string>
<string name="screen_edit_poll_delete_confirmation">"Czy na pewno chcesz usunąć tę ankietę?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Usuń ankietę"</string>
<string name="screen_edit_poll_title">"Edytuj ankietę"</string>
<string name="screen_polls_history_empty_ongoing">"Nie znaleziono ankiet w trakcie."</string>
<string name="screen_polls_history_empty_past">"Nie znaleziono ankiet."</string>
<string name="screen_polls_history_filter_ongoing">"W trakcie"</string>
<string name="screen_polls_history_filter_past">"Przeszłe"</string>
<string name="screen_polls_history_title">"Ankiety"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Adicionar opção"</string>
<string name="screen_create_poll_anonymous_desc">"Mostrar resultados somente após o término da enquete"</string>
<string name="screen_create_poll_anonymous_headline">"Ocultar votos"</string>
<string name="screen_create_poll_answer_hint">"%1$dª opção"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Suas alterações não foram salvas. Tem certeza de que você quer voltar?"</string>
<string name="screen_create_poll_delete_option_a11y">"Apagar opção %1$s"</string>
<string name="screen_create_poll_question_desc">"Pergunta ou tópico"</string>
<string name="screen_create_poll_question_hint">"Sobre o que é a enquete?"</string>
<string name="screen_create_poll_title">"Criar enquete"</string>
<string name="screen_edit_poll_delete_confirmation">"Tem certeza de que quer apagar esta enquete?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Excluir enquete"</string>
<string name="screen_edit_poll_title">"Editar enquete"</string>
<string name="screen_polls_history_empty_ongoing">"Não foi possível encontrar nenhuma enquete em andamento."</string>
<string name="screen_polls_history_empty_past">"Não foi possível encontrar nenhuma enquete anterior."</string>
<string name="screen_polls_history_filter_ongoing">"Em andamento"</string>
<string name="screen_polls_history_filter_past">"Anteriores"</string>
<string name="screen_polls_history_title">"Enquetes"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Adicionar opção"</string>
<string name="screen_create_poll_anonymous_desc">"Mostrar resultados só após o da sondagem"</string>
<string name="screen_create_poll_anonymous_headline">"Ocultar votos"</string>
<string name="screen_create_poll_answer_hint">"Opção %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"As tuas alterações não foram guardadas. Tens a certeza que queres voltar atrás?"</string>
<string name="screen_create_poll_delete_option_a11y">"Eliminar opção %1$s"</string>
<string name="screen_create_poll_question_desc">"Pergunta ou tópico"</string>
<string name="screen_create_poll_question_hint">"De que trata a sondagem?"</string>
<string name="screen_create_poll_title">"Criar sondagem"</string>
<string name="screen_edit_poll_delete_confirmation">"Tens a certeza que queres apagar esta sondagem?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Eliminar sondagem"</string>
<string name="screen_edit_poll_title">"Editar sondagem"</string>
<string name="screen_polls_history_empty_ongoing">"Não foi possível encontrar nenhuma sondagem em curso."</string>
<string name="screen_polls_history_empty_past">"Não foi possível encontrar nenhuma sondagem anterior."</string>
<string name="screen_polls_history_filter_ongoing">"Em curso"</string>
<string name="screen_polls_history_filter_past">"Passado"</string>
<string name="screen_polls_history_title">"Sondagens"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Adăugați o opțiune"</string>
<string name="screen_create_poll_anonymous_desc">"Afișați rezultatele numai după încheierea sondajului"</string>
<string name="screen_create_poll_anonymous_headline">"Sondaj anonim"</string>
<string name="screen_create_poll_answer_hint">"Opțiune %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Modificările dumneavoastră nu au fost salvate. Sunteți sigur că doriți să vă întoarceți?"</string>
<string name="screen_create_poll_delete_option_a11y">"Ștergeți opțiunea %1$s"</string>
<string name="screen_create_poll_question_desc">"Întrebare sau subiect"</string>
<string name="screen_create_poll_question_hint">"Despre ce este sondajul?"</string>
<string name="screen_create_poll_title">"Creați un sondaj"</string>
<string name="screen_edit_poll_delete_confirmation">"Sunteți sigur că doriți să ștergeți acest sondaj?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Ștergeți sondajul"</string>
<string name="screen_edit_poll_title">"Editați sondajul"</string>
<string name="screen_polls_history_empty_ongoing">"Nu s-au putut găsi sondaje în curs de desfășurare."</string>
<string name="screen_polls_history_empty_past">"Nu s-au putut găsi sondaje anterioare."</string>
<string name="screen_polls_history_filter_ongoing">"În desfășurare"</string>
<string name="screen_polls_history_filter_past">"Trecut"</string>
<string name="screen_polls_history_title">"Sondaje"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Добавить вариант"</string>
<string name="screen_create_poll_anonymous_desc">"Показывать результаты только после окончания опроса"</string>
<string name="screen_create_poll_anonymous_headline">"Скрыть голоса"</string>
<string name="screen_create_poll_answer_hint">"Вариант %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Изменения не сохранены. Вы действительно хотите вернуться?"</string>
<string name="screen_create_poll_delete_option_a11y">"Удалить опцию %1$s"</string>
<string name="screen_create_poll_question_desc">"Вопрос или тема"</string>
<string name="screen_create_poll_question_hint">"О чём будет опрос?"</string>
<string name="screen_create_poll_title">"Создать опрос"</string>
<string name="screen_edit_poll_delete_confirmation">"Вы уверены, что хотите удалить этот опрос?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Удалить опрос"</string>
<string name="screen_edit_poll_title">"Редактировать опрос"</string>
<string name="screen_polls_history_empty_ongoing">"Не найдено текущих опросов."</string>
<string name="screen_polls_history_empty_past">"Не найдено прошлых опросов."</string>
<string name="screen_polls_history_filter_ongoing">"Текущие"</string>
<string name="screen_polls_history_filter_past">"Прошлые"</string>
<string name="screen_polls_history_title">"Опросы"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Pridať možnosť"</string>
<string name="screen_create_poll_anonymous_desc">"Zobraziť výsledky až po skončení ankety"</string>
<string name="screen_create_poll_anonymous_headline">"Anonymná anketa"</string>
<string name="screen_create_poll_answer_hint">"Možnosť %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Vaše zmeny neboli uložené. Naozaj sa chcete vrátiť?"</string>
<string name="screen_create_poll_delete_option_a11y">"Odstrániť možnosť %1$s"</string>
<string name="screen_create_poll_question_desc">"Otázka alebo téma"</string>
<string name="screen_create_poll_question_hint">"O čom je anketa?"</string>
<string name="screen_create_poll_title">"Vytvoriť anketu"</string>
<string name="screen_edit_poll_delete_confirmation">"Ste si istý, že chcete odstrániť túto anketu?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Odstrániť anketu"</string>
<string name="screen_edit_poll_title">"Upraviť anketu"</string>
<string name="screen_polls_history_empty_ongoing">"Nepodarilo sa nájsť žiadne prebiehajúce ankety."</string>
<string name="screen_polls_history_empty_past">"Nie je možné nájsť žiadne predchádzajúce ankety."</string>
<string name="screen_polls_history_filter_ongoing">"Prebiehajúce"</string>
<string name="screen_polls_history_filter_past">"Minulé"</string>
<string name="screen_polls_history_title">"Ankety"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Lägg till alternativ"</string>
<string name="screen_create_poll_anonymous_desc">"Visa resultat först efter att omröstningen avslutats"</string>
<string name="screen_create_poll_anonymous_headline">"Dölj röster"</string>
<string name="screen_create_poll_answer_hint">"Alternativ %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Dina ändringar har inte sparats. Är du säker på att du vill gå tillbaka?"</string>
<string name="screen_create_poll_delete_option_a11y">"Radera alternativet %1$s"</string>
<string name="screen_create_poll_question_desc">"Fråga eller ämne"</string>
<string name="screen_create_poll_question_hint">"Vad handlar omröstningen om?"</string>
<string name="screen_create_poll_title">"Skapa omröstning"</string>
<string name="screen_edit_poll_delete_confirmation">"Är du säker på att du vill radera den här omröstningen?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Radera omröstning"</string>
<string name="screen_edit_poll_title">"Redigera omröstning"</string>
<string name="screen_polls_history_empty_ongoing">"Kan inte hitta några pågående omröstningar."</string>
<string name="screen_polls_history_empty_past">"Kan inte hitta några tidigare omröstningar."</string>
<string name="screen_polls_history_filter_ongoing">"Pågående"</string>
<string name="screen_polls_history_filter_past">"Tidigare"</string>
<string name="screen_polls_history_title">"Omröstningar"</string>
</resources>
@@ -0,0 +1,19 @@
<?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">"Seçenek ekle"</string>
<string name="screen_create_poll_anonymous_desc">"Sonuçları yalnızca anket bittikten sonra göster"</string>
<string name="screen_create_poll_anonymous_headline">"Oyları gizle"</string>
<string name="screen_create_poll_answer_hint">"Seçenek %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Değişiklikleriniz kaydedilmedi. Geri dönmek istediğinden emin misin?"</string>
<string name="screen_create_poll_question_desc">"Soru veya konu"</string>
<string name="screen_create_poll_question_hint">"Anket ne hakkında?"</string>
<string name="screen_create_poll_title">"Anket Oluştur"</string>
<string name="screen_edit_poll_delete_confirmation">"Bu anketi silmek istediğinize emin misiniz?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Anketi Sil"</string>
<string name="screen_edit_poll_title">"Anketi düzenle"</string>
<string name="screen_polls_history_empty_ongoing">"Devam eden bir anket bulamadım."</string>
<string name="screen_polls_history_empty_past">"Geçmiş herhangi bir anket bulamıyorum."</string>
<string name="screen_polls_history_filter_ongoing">"Devam ediyor"</string>
<string name="screen_polls_history_filter_past">"Geçmiş"</string>
<string name="screen_polls_history_title">"Anketler"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Додати варіант"</string>
<string name="screen_create_poll_anonymous_desc">"Показувати результати тільки після закінчення опитування"</string>
<string name="screen_create_poll_anonymous_headline">"Приховати голоси"</string>
<string name="screen_create_poll_answer_hint">"Варіант %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Внесені зміни не збережено. Ви впевнені, що хочете повернутися?"</string>
<string name="screen_create_poll_delete_option_a11y">"Видалити варіант %1$s"</string>
<string name="screen_create_poll_question_desc">"Питання або тема"</string>
<string name="screen_create_poll_question_hint">"Про що йдеться в опитуванні?"</string>
<string name="screen_create_poll_title">"Створити опитування"</string>
<string name="screen_edit_poll_delete_confirmation">"Ви впевнені, що хочете видалити це опитування?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Видалити опитування"</string>
<string name="screen_edit_poll_title">"Редагувати опитування"</string>
<string name="screen_polls_history_empty_ongoing">"Не вдалося знайти жодних поточних опитувань."</string>
<string name="screen_polls_history_empty_past">"Не вдалося знайти жодних минулих опитувань."</string>
<string name="screen_polls_history_filter_ongoing">"Поточні"</string>
<string name="screen_polls_history_filter_past">"Минулі"</string>
<string name="screen_polls_history_title">"Опитування"</string>
</resources>
@@ -0,0 +1,19 @@
<?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">"اختیار شامل کریں"</string>
<string name="screen_create_poll_anonymous_desc">"رائے شماری کے بعد ہی نتائج ظاہر کریں"</string>
<string name="screen_create_poll_anonymous_headline">"آراء چھپائیں"</string>
<string name="screen_create_poll_answer_hint">"اختیار %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"آپ کی تبدیلیاں محفوظ نہیں کی گئیں۔ کیا آپ کو یقین ہے کہ آپ واپس جانا چاہتے ہیں؟"</string>
<string name="screen_create_poll_question_desc">"سوال یا موضوع"</string>
<string name="screen_create_poll_question_hint">"رائے شماری کس بارے میں ہے؟"</string>
<string name="screen_create_poll_title">"رائے شماری بنائیں"</string>
<string name="screen_edit_poll_delete_confirmation">"کیا آپ کو یقین ہے کہ آپ اس رائے شماری کو حذف کرنا چاہتے ہیں؟"</string>
<string name="screen_edit_poll_delete_confirmation_title">"رائے شماری حذف کریں"</string>
<string name="screen_edit_poll_title">"رائے شماری میں ترمیم کریں"</string>
<string name="screen_polls_history_empty_ongoing">"کوئی جاری رائے شماری ہا نہیں مل سکے۔"</string>
<string name="screen_polls_history_empty_past">"ماضی کے کوئی رائے شماری ہا نہیں مل سکے۔"</string>
<string name="screen_polls_history_filter_ongoing">"جاری"</string>
<string name="screen_polls_history_filter_past">"ماضی"</string>
<string name="screen_polls_history_title">"رائے شماری ہا"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Variant qo\'shish"</string>
<string name="screen_create_poll_anonymous_desc">"Natijalarni faqat soʻrov tugagandan keyin koʻrsatish"</string>
<string name="screen_create_poll_anonymous_headline">"Ovozlarni yashirish"</string>
<string name="screen_create_poll_answer_hint">"Variant%1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Oʻzgarishlar saqlanmadi. Haqiqatan ham orqaga qaytmoqchimisiz?"</string>
<string name="screen_create_poll_delete_option_a11y">"%1$s variantini ochirish"</string>
<string name="screen_create_poll_question_desc">"Savol yoki mavzu"</string>
<string name="screen_create_poll_question_hint">"So\'rovnoma nima haqida?"</string>
<string name="screen_create_poll_title">"Sorovnoma yaratish"</string>
<string name="screen_edit_poll_delete_confirmation">"Siz rostdan ham bu soʻrovnomani oʻchirib tashlamoqchimisiz?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Sorovnomani ochirish"</string>
<string name="screen_edit_poll_title">"Sorovnomani tahrirlash"</string>
<string name="screen_polls_history_empty_ongoing">"Davom etayotgan soʻrovlar topilmadi."</string>
<string name="screen_polls_history_empty_past">"Avvalgi soʻrovnomalar topilmadi."</string>
<string name="screen_polls_history_filter_ongoing">"Jarayonda"</string>
<string name="screen_polls_history_filter_past">"Oʻtgan"</string>
<string name="screen_polls_history_title">"Soʻrovnomalar"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"新增選項"</string>
<string name="screen_create_poll_anonymous_desc">"只在投票結束後顯示結果"</string>
<string name="screen_create_poll_anonymous_headline">"隱藏票數"</string>
<string name="screen_create_poll_answer_hint">"選項 %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"變更尚未儲存,您確定要返回嗎?"</string>
<string name="screen_create_poll_delete_option_a11y">"刪除選項 %1$s"</string>
<string name="screen_create_poll_question_desc">"問題或主題"</string>
<string name="screen_create_poll_question_hint">"投什麼?"</string>
<string name="screen_create_poll_title">"建立投票"</string>
<string name="screen_edit_poll_delete_confirmation">"您確定要刪除投票嗎?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"刪除投票"</string>
<string name="screen_edit_poll_title">"編輯投票"</string>
<string name="screen_polls_history_empty_ongoing">"沒有進行中的投票。"</string>
<string name="screen_polls_history_empty_past">"沒有已結束的投票。"</string>
<string name="screen_polls_history_filter_ongoing">"進行中"</string>
<string name="screen_polls_history_filter_past">"已結束"</string>
<string name="screen_polls_history_title">"所有投票"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"添加选项"</string>
<string name="screen_create_poll_anonymous_desc">"仅在投票结束后显示结果"</string>
<string name="screen_create_poll_anonymous_headline">"隐藏投票"</string>
<string name="screen_create_poll_answer_hint">"选项 %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"更改尚未保存,确定要返回吗?"</string>
<string name="screen_create_poll_delete_option_a11y">"删除选项%1$s"</string>
<string name="screen_create_poll_question_desc">"问题或话题"</string>
<string name="screen_create_poll_question_hint">"投票的内容是什么?"</string>
<string name="screen_create_poll_title">"创建投票"</string>
<string name="screen_edit_poll_delete_confirmation">"您确定要删除此投票吗?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"删除投票"</string>
<string name="screen_edit_poll_title">"编辑投票"</string>
<string name="screen_polls_history_empty_ongoing">"无法找到正在进行的投票。"</string>
<string name="screen_polls_history_empty_past">"无法找到历史投票"</string>
<string name="screen_polls_history_filter_ongoing">"正在进行"</string>
<string name="screen_polls_history_filter_past">"历史"</string>
<string name="screen_polls_history_title">"投票"</string>
</resources>
@@ -0,0 +1,20 @@
<?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">"Hide votes"</string>
<string name="screen_create_poll_answer_hint">"Option %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Your changes have not been saved. Are you sure you want to go back?"</string>
<string name="screen_create_poll_delete_option_a11y">"Delete option %1$s"</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>
<string name="screen_edit_poll_delete_confirmation">"Are you sure you want to delete this poll?"</string>
<string name="screen_edit_poll_delete_confirmation_title">"Delete Poll"</string>
<string name="screen_edit_poll_title">"Edit poll"</string>
<string name="screen_polls_history_empty_ongoing">"Can\'t find any ongoing polls."</string>
<string name="screen_polls_history_empty_past">"Can\'t find any past polls."</string>
<string name="screen_polls_history_filter_ongoing">"Ongoing"</string>
<string name="screen_polls_history_filter_past">"Past"</string>
<string name="screen_polls_history_title">"Polls"</string>
</resources>
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.poll.PollAnswer
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.timeline.aPollContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
fun aPollTimelineItems(
polls: Map<EventId, PollContent> = emptyMap(),
): Flow<List<MatrixTimelineItem>> {
return flowOf(
polls.map { entry ->
MatrixTimelineItem.Event(
uniqueId = UniqueId(entry.key.value),
event = anEventTimelineItem(
eventId = entry.key,
content = entry.value,
)
)
}
)
}
fun anOngoingPollContent() = aPollContent(
question = "Do you like polls?",
answers = persistentListOf(
PollAnswer("1", "Yes"),
PollAnswer("2", "No"),
PollAnswer("2", "Maybe"),
),
)
fun anEndedPollContent() = anOngoingPollContent().copy(
endTime = 1702400215U
)
@@ -0,0 +1,576 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
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.assertThat
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.aPollTimelineItems
import io.element.android.features.poll.impl.anOngoingPollContent
import io.element.android.features.poll.impl.data.PollRepository
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.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class CreatePollPresenterTest {
@get:Rule val warmUpRule = WarmUpRule()
private val pollEventId = AN_EVENT_ID
private var navUpInvocationsCount = 0
private val existingPoll = anOngoingPollContent()
private val timeline = FakeTimeline(
timelineItems = aPollTimelineItems(mapOf(pollEventId to existingPoll))
)
private val fakeJoinedRoom = FakeJoinedRoom(
liveTimeline = timeline
)
private val fakeAnalyticsService = FakeAnalyticsService()
private val fakeMessageComposerContext = FakeMessageComposerContext()
@Test
fun `default state has proper default values`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
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 = FakeJoinedRoom(
liveTimeline = FakeTimeline()
)
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(AN_EVENT_ID), room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
assertThat(fakeAnalyticsService.trackedErrors.filterIsInstance<CreatePollException.GetPollFailed>()).isNotEmpty()
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()
assertThat(initial.canSave).isFalse()
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
val questionSet = awaitItem()
assertThat(questionSet.canSave).isFalse()
questionSet.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
val answer1Set = awaitItem()
assertThat(answer1Set.canSave).isFalse()
answer1Set.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
val answer2Set = awaitItem()
assertThat(answer2Set.canSave).isTrue()
}
}
@Test
fun `create poll sends a poll start event`() = runTest {
val createPollResult = lambdaRecorder<String, List<String>, Int, PollKind, Result<Unit>> { _, _, _, _ -> Result.success(Unit) }
val presenter = createCreatePollPresenter(
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
createPollLambda = createPollResult
},
),
mode = CreatePollMode.NewPoll,
)
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.Save)
delay(1) // Wait for the coroutine to finish
createPollResult.assertions().isCalledOnce()
.with(
value("A question?"),
value(listOf("Answer 1", "Answer 2")),
value(1),
value(PollKind.Disclosed),
)
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
Composer(
inThread = false,
isEditing = false,
isReply = false,
messageType = Composer.MessageType.Poll,
)
)
assertThat(fakeAnalyticsService.capturedEvents[1]).isEqualTo(
PollCreation(
action = PollCreation.Action.Create,
isUndisclosed = false,
numberOfAnswers = 2,
)
)
}
}
@Test
fun `when poll creation fails, error is tracked`() = runTest {
val error = Exception("cause")
val createPollResult = lambdaRecorder<String, List<String>, Int, PollKind, Result<Unit>> { _, _, _, _ ->
Result.failure(error)
}
val presenter = createCreatePollPresenter(
room = FakeJoinedRoom(
liveTimeline = FakeTimeline().apply {
createPollLambda = createPollResult
},
),
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
createPollResult.assertions().isCalledOnce()
assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
assertThat(fakeAnalyticsService.trackedErrors).hasSize(1)
assertThat(fakeAnalyticsService.trackedErrors).containsExactly(
CreatePollException.SavePollFailed("Failed to create poll", error)
)
}
}
@Test
fun `edit poll sends a poll edit event`() = runTest {
val editPollLambda = lambdaRecorder { _: EventId, _: String, _: List<String>, _: Int, _: PollKind ->
Result.success(Unit)
}
timeline.apply {
this.editPollLambda = editPollLambda
}
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)
}
advanceUntilIdle() // Wait for the coroutine to finish
assert(editPollLambda)
.isCalledOnce()
.with(
value(pollEventId),
value("Changed question"),
value(listOf("Changed answer 1", "Changed answer 2", "Maybe")),
value(1),
value(PollKind.Disclosed)
)
assertThat(fakeAnalyticsService.capturedEvents.size).isEqualTo(2)
assertThat(fakeAnalyticsService.capturedEvents[0]).isEqualTo(
Composer(
inThread = false,
isEditing = true,
isReply = false,
messageType = Composer.MessageType.Poll,
)
)
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")
val presenter = createCreatePollPresenter(
room = FakeJoinedRoom(
liveTimeline = timeline,
),
mode = CreatePollMode.EditPoll(pollEventId),
)
val editPollLambda = lambdaRecorder { _: EventId, _: String, _: List<String>, _: Int, _: PollKind ->
Result.failure<Unit>(error)
}
timeline.apply {
this.editPollLambda = editPollLambda
}
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
awaitPollLoaded().eventSink(CreatePollEvents.SetAnswer(0, "A"))
awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvents.Save)
advanceUntilIdle() // Wait for the coroutine to finish
editPollLambda.assertions().isCalledOnce()
assertThat(fakeAnalyticsService.capturedEvents).isEmpty()
assertThat(fakeAnalyticsService.trackedErrors).hasSize(1)
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 {
val initial = awaitItem()
assertThat(initial.answers.size).isEqualTo(2)
initial.eventSink(CreatePollEvents.AddAnswer)
val answerAdded = awaitItem()
assertThat(answerAdded.answers.size).isEqualTo(3)
assertThat(answerAdded.answers[2].text).isEmpty()
initial.eventSink(CreatePollEvents.RemoveAnswer(2))
val answerRemoved = awaitItem()
assertThat(answerRemoved.answers.size).isEqualTo(2)
}
}
@Test
fun `set question sets the question`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
val questionSet = awaitItem()
assertThat(questionSet.question).isEqualTo("A question?")
}
}
@Test
fun `set poll answer sets the given poll answer`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
initial.eventSink(CreatePollEvents.SetAnswer(0, "This is answer 1"))
val answerSet = awaitItem()
assertThat(answerSet.answers.first().text).isEqualTo("This is answer 1")
}
}
@Test
fun `set poll kind sets the poll kind`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
initial.eventSink(CreatePollEvents.SetPollKind(PollKind.Undisclosed))
val kindSet = awaitItem()
assertThat(kindSet.pollKind).isEqualTo(PollKind.Undisclosed)
}
}
@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()
assertThat(initial.canAddAnswer).isTrue()
repeat(17) {
initial.eventSink(CreatePollEvents.AddAnswer)
assertThat(awaitItem().canAddAnswer).isTrue()
}
initial.eventSink(CreatePollEvents.AddAnswer)
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()
assertThat(initial.answers.all { it.canDelete }).isFalse()
initial.eventSink(CreatePollEvents.AddAnswer)
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 {
val initial = awaitItem()
initial.eventSink(CreatePollEvents.SetAnswer(0, "A".repeat(241)))
assertThat(awaitItem().answers.first().text.length).isEqualTo(240)
}
}
@Test
fun `navBack event calls navBack lambda`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
assertThat(navUpInvocationsCount).isEqualTo(0)
initial.eventSink(CreatePollEvents.NavBack)
assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@Test
fun `confirm nav back from new poll with blank fields calls nav back lambda`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
assertThat(navUpInvocationsCount).isEqualTo(0)
assertThat(initial.showBackConfirmation).isFalse()
initial.eventSink(CreatePollEvents.ConfirmNavBack)
assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@Test
fun `confirm nav back from new poll with non blank fields shows confirmation dialog and cancelling hides it`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.NewPoll)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initial = awaitItem()
initial.eventSink(CreatePollEvents.SetQuestion("Non blank"))
assertThat(awaitItem().showBackConfirmation).isFalse()
initial.eventSink(CreatePollEvents.ConfirmNavBack)
assertThat(awaitItem().showBackConfirmation).isTrue()
initial.eventSink(CreatePollEvents.HideConfirmation)
assertThat(awaitItem().showBackConfirmation).isFalse()
assertThat(navUpInvocationsCount).isEqualTo(0)
}
}
@Test
fun `confirm nav back from existing poll with unchanged fields calls nav back lambda`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
val loaded = awaitPollLoaded()
assertThat(navUpInvocationsCount).isEqualTo(0)
assertThat(loaded.showBackConfirmation).isFalse()
loaded.eventSink(CreatePollEvents.ConfirmNavBack)
assertThat(navUpInvocationsCount).isEqualTo(1)
}
}
@Test
fun `confirm nav back from existing poll with changed fields shows confirmation dialog and cancelling hides it`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
val loaded = awaitPollLoaded()
loaded.eventSink(CreatePollEvents.SetQuestion("CHANGED"))
assertThat(awaitItem().showBackConfirmation).isFalse()
loaded.eventSink(CreatePollEvents.ConfirmNavBack)
assertThat(awaitItem().showBackConfirmation).isTrue()
loaded.eventSink(CreatePollEvents.HideConfirmation)
assertThat(awaitItem().showBackConfirmation).isFalse()
assertThat(navUpInvocationsCount).isEqualTo(0)
}
}
@Test
fun `delete confirms`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) }
timeline.redactEventLambda = redactEventLambda
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false))
awaitDeleteConfirmation()
assert(redactEventLambda).isNeverCalled()
}
}
@Test
fun `delete can be cancelled`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) }
timeline.redactEventLambda = redactEventLambda
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false))
awaitDeleteConfirmation().eventSink(CreatePollEvents.HideConfirmation)
awaitPollLoaded().apply {
assertThat(showDeleteConfirmation).isFalse()
}
assert(redactEventLambda).isNeverCalled()
}
}
@Test
fun `delete can be confirmed`() = runTest {
val presenter = createCreatePollPresenter(mode = CreatePollMode.EditPoll(pollEventId))
val redactEventLambda = lambdaRecorder { _: EventOrTransactionId, _: String? -> Result.success(Unit) }
timeline.redactEventLambda = redactEventLambda
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitDefaultItem()
awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false))
awaitDeleteConfirmation().eventSink(CreatePollEvents.Delete(confirmed = true))
awaitPollLoaded().apply {
assertThat(showDeleteConfirmation).isFalse()
}
assert(redactEventLambda)
.isCalledOnce()
.with(value(pollEventId.toEventOrTransactionId()), any())
}
}
private suspend fun TurbineTestContext<CreatePollState>.awaitDefaultItem() =
awaitItem().apply {
assertThat(canSave).isFalse()
assertThat(canAddAnswer).isTrue()
assertThat(question).isEmpty()
assertThat(answers).isEqualTo(listOf(Answer("", false), Answer("", false)))
assertThat(pollKind).isEqualTo(PollKind.Disclosed)
assertThat(showBackConfirmation).isFalse()
assertThat(showDeleteConfirmation).isFalse()
}
private suspend fun TurbineTestContext<CreatePollState>.awaitDeleteConfirmation() =
awaitItem().apply {
assertThat(showDeleteConfirmation).isTrue()
}
private suspend fun TurbineTestContext<CreatePollState>.awaitPollLoaded(
newQuestion: String? = null,
newAnswer1: String? = null,
newAnswer2: String? = null,
) =
awaitItem().also { state ->
assertThat(state.canSave).isTrue()
assertThat(state.canAddAnswer).isTrue()
assertThat(state.question).isEqualTo(newQuestion ?: existingPoll.question)
assertThat(state.answers).isEqualTo(existingPoll.expectedAnswersState().toMutableList().apply {
newAnswer1?.let { this[0] = Answer(it, true) }
newAnswer2?.let { this[1] = Answer(it, true) }
})
assertThat(state.pollKind).isEqualTo(existingPoll.kind)
}
private fun createCreatePollPresenter(
mode: CreatePollMode = CreatePollMode.NewPoll,
room: FakeJoinedRoom = fakeJoinedRoom,
timelineMode: Timeline.Mode = Timeline.Mode.Live,
): CreatePollPresenter = CreatePollPresenter(
repositoryFactory = object : PollRepository.Factory {
override fun create(timelineMode: Timeline.Mode): PollRepository {
return PollRepository(room, LiveTimelineProvider(room), timelineMode)
}
},
analyticsService = fakeAnalyticsService,
messageComposerContext = fakeMessageComposerContext,
navigateUp = { navUpInvocationsCount++ },
mode = mode,
timelineMode = timelineMode,
)
}
private fun PollContent.expectedAnswersState() = answers.map { answer ->
Answer(
text = answer.text,
canDelete = answers.size > 2,
)
}
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.create
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.features.poll.api.create.CreatePollEntryPoint
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.timeline.Timeline
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.LiveTimelineProvider
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.node.TestParentNode
import org.junit.Rule
import org.junit.Test
class DefaultCreatePollEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun `test node builder`() {
val entryPoint = DefaultCreatePollEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
CreatePollNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { timelineMode: Timeline.Mode, backNavigator: () -> Unit, mode: CreatePollMode ->
CreatePollPresenter(
repositoryFactory = {
val room = FakeJoinedRoom()
PollRepository(room, LiveTimelineProvider(room), timelineMode)
},
analyticsService = FakeAnalyticsService(),
messageComposerContext = FakeMessageComposerContext(),
navigateUp = backNavigator,
mode = mode,
timelineMode = timelineMode,
)
},
analyticsService = FakeAnalyticsService(),
)
}
val params = CreatePollEntryPoint.Params(
timelineMode = Timeline.Mode.Live,
mode = CreatePollMode.NewPoll,
)
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
params = params,
)
assertThat(result).isInstanceOf(CreatePollNode::class.java)
assertThat(result.plugins).contains(CreatePollNode.Inputs(timelineMode = params.timelineMode, mode = params.mode))
}
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.create
import androidx.compose.runtime.saveable.SaverScope
import com.google.common.truth.Truth.assertThat
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class PollFormStateSaverTest {
companion object {
val CanSaveScope = SaverScope { true }
}
@Test
fun `test save and restore`() {
val state = PollFormState(
question = "question",
answers = persistentListOf("answer1", "answer2"),
isDisclosed = true,
)
val saved = with(CanSaveScope) {
with(pollFormStateSaver) {
save(state)
}
}
val restored = saved?.let {
pollFormStateSaver.restore(it)
}
assertThat(restored).isEqualTo(state)
}
}
@@ -0,0 +1,137 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.create
import com.google.common.truth.Truth.assertThat
import io.element.android.features.poll.impl.PollConstants
import io.element.android.libraries.matrix.api.poll.PollKind
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test
class PollFormStateTest {
@Test
fun `with new answer`() {
val state = PollFormState.Empty
val newState = state.withNewAnswer()
assertThat(newState.answers).isEqualTo(listOf("", "", ""))
}
@Test
fun `with new answer, given cannot add, doesn't add`() {
val state = PollFormState.Empty.withBlankAnswers(PollConstants.MAX_ANSWERS)
val newState = state.withNewAnswer()
assertThat(newState).isEqualTo(state)
}
@Test
fun `with answer deleted, given cannot delete, doesn't delete`() {
val state = PollFormState.Empty
val newState = state.withAnswerRemoved(0)
assertThat(newState).isEqualTo(state)
}
@Test
fun `with answer deleted, given can delete`() {
val state = PollFormState.Empty.withNewAnswer()
val newState = state.withAnswerRemoved(0)
assertThat(newState).isEqualTo(PollFormState.Empty)
}
@Test
fun `with answer changed`() {
val state = PollFormState.Empty
val newState = state.withAnswerChanged(1, "New answer")
assertThat(newState).isEqualTo(PollFormState.Empty.copy(
answers = listOf("", "New answer").toImmutableList()
))
}
@Test
fun `with answer changed, given it is too long, truncates`() {
val tooLongAnswer = "a".repeat(PollConstants.MAX_ANSWER_LENGTH * 2)
val truncatedAnswer = "a".repeat(PollConstants.MAX_ANSWER_LENGTH)
val state = PollFormState.Empty
val newState = state.withAnswerChanged(1, tooLongAnswer)
assertThat(newState).isEqualTo(PollFormState.Empty.copy(
answers = listOf("", truncatedAnswer).toImmutableList()
))
}
@Test
fun `can add answer is true when it does not have max answers`() {
val state = PollFormState.Empty.withBlankAnswers(PollConstants.MAX_ANSWERS - 1)
assertThat(state.canAddAnswer).isTrue()
}
@Test
fun `can add answer is false when it has max answers`() {
val state = PollFormState.Empty.withBlankAnswers(PollConstants.MAX_ANSWERS)
assertThat(state.canAddAnswer).isFalse()
}
@Test
fun `can delete answer is false when it has min answers`() {
val state = PollFormState.Empty.withBlankAnswers(PollConstants.MIN_ANSWERS)
assertThat(state.canDeleteAnswer).isFalse()
}
@Test
fun `can delete answer is true when it has more than min answers`() {
val numAnswers = PollConstants.MIN_ANSWERS + 1
val state = PollFormState.Empty.withBlankAnswers(numAnswers)
assertThat(state.canDeleteAnswer).isTrue()
}
@Test
fun `is valid is true when it is valid`() {
val state = aValidPollFormState()
assertThat(state.isValid).isTrue()
}
@Test
fun `is valid is false when question is blank`() {
val state = aValidPollFormState().copy(question = "")
assertThat(state.isValid).isFalse()
}
@Test
fun `is valid is false when not enough answers`() {
val state = aValidPollFormState().copy(answers = listOf("").toImmutableList())
assertThat(state.isValid).isFalse()
}
@Test
fun `is valid is false when one answer is blank`() {
val state = aValidPollFormState().withNewAnswer()
assertThat(state.isValid).isFalse()
}
@Test
fun `poll kind when is disclosed`() {
val state = PollFormState.Empty.copy(isDisclosed = true)
assertThat(state.pollKind).isEqualTo(PollKind.Disclosed)
}
@Test
fun `poll kind when is not disclosed`() {
val state = PollFormState.Empty.copy(isDisclosed = false)
assertThat(state.pollKind).isEqualTo(PollKind.Undisclosed)
}
}
private fun aValidPollFormState(): PollFormState {
return PollFormState.Empty.copy(
question = "question",
answers = listOf("answer1", "answer2").toImmutableList(),
isDisclosed = true,
)
}
private fun PollFormState.withBlankAnswers(numAnswers: Int): PollFormState =
copy(answers = List(numAnswers) { "" }.toImmutableList())
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.features.poll.test.create.FakeCreatePollEntryPoint
import io.element.android.tests.testutils.node.TestParentNode
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DefaultPollHistoryEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultPollHistoryEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
PollHistoryFlowNode(
buildContext = buildContext,
plugins = plugins,
createPollEntryPoint = FakeCreatePollEntryPoint(),
)
}
val result = entryPoint.createNode(parentNode, BuildContext.root(null))
assertThat(result).isInstanceOf(PollHistoryFlowNode::class.java)
}
}
@@ -0,0 +1,174 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.poll.api.actions.EndPollAction
import io.element.android.features.poll.api.actions.SendPollResponseAction
import io.element.android.features.poll.impl.aPollTimelineItems
import io.element.android.features.poll.impl.anEndedPollContent
import io.element.android.features.poll.impl.anOngoingPollContent
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.features.poll.impl.history.model.PollHistoryItemsFactory
import io.element.android.features.poll.impl.model.DefaultPollContentStateFactory
import io.element.android.features.poll.test.actions.FakeEndPollAction
import io.element.android.features.poll.test.actions.FakeSendPollResponseAction
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.getAndUpdate
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class PollHistoryPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
private val backwardPaginationStatus = MutableStateFlow(Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = true))
private val timeline = FakeTimeline(
timelineItems = aPollTimelineItems(
mapOf(
AN_EVENT_ID to anOngoingPollContent(),
AN_EVENT_ID_2 to anEndedPollContent()
)
),
backwardPaginationStatus = backwardPaginationStatus
)
private val room = FakeJoinedRoom(
liveTimeline = timeline
)
@Test
fun `present - initial states`() = runTest {
val presenter = createPollHistoryPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().also { state ->
assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING)
assertThat(state.pollHistoryItems.size).isEqualTo(0)
assertThat(state.isLoading).isTrue()
assertThat(state.hasMoreToLoad).isTrue()
}
awaitItem().also { state ->
assertThat(state.pollHistoryItems.size).isEqualTo(2)
assertThat(state.pollHistoryItems.ongoing).hasSize(1)
assertThat(state.pollHistoryItems.past).hasSize(1)
}
}
}
@Test
fun `present - change filter scenario`() = runTest {
val presenter = createPollHistoryPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().also { state ->
assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING)
state.eventSink(PollHistoryEvents.SelectFilter(PollHistoryFilter.PAST))
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.PAST)
state.eventSink(PollHistoryEvents.SelectFilter(PollHistoryFilter.ONGOING))
}
awaitItem().also { state ->
assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - poll actions scenario`() = runTest {
val sendPollResponseAction = FakeSendPollResponseAction()
val endPollAction = FakeEndPollAction()
val presenter = createPollHistoryPresenter(
sendPollResponseAction = sendPollResponseAction,
endPollAction = endPollAction
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val state = awaitItem()
state.eventSink(PollHistoryEvents.EndPoll(AN_EVENT_ID))
runCurrent()
endPollAction.verifyExecutionCount(1)
state.eventSink(PollHistoryEvents.SelectPollAnswer(AN_EVENT_ID, "answer"))
runCurrent()
sendPollResponseAction.verifyExecutionCount(1)
cancelAndConsumeRemainingEvents()
}
}
@Test
fun `present - load more scenario`() = runTest {
val paginateLambda = lambdaRecorder { _: Timeline.PaginationDirection ->
Result.success(false)
}
timeline.apply {
this.paginateLambda = paginateLambda
}
val presenter = createPollHistoryPresenter(room = room)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val loadedState = awaitItem()
assertThat(loadedState.isLoading).isFalse()
loadedState.eventSink(PollHistoryEvents.LoadMore)
backwardPaginationStatus.getAndUpdate { it.copy(isPaginating = true) }
awaitItem().also { state ->
assertThat(state.isLoading).isTrue()
}
backwardPaginationStatus.getAndUpdate { it.copy(isPaginating = false) }
awaitItem().also { state ->
assertThat(state.isLoading).isFalse()
}
// Called once by the initial load and once by the load more event
assert(paginateLambda).isCalledExactly(2)
}
}
}
internal fun TestScope.createPollHistoryPresenter(
room: FakeJoinedRoom = FakeJoinedRoom(),
endPollAction: EndPollAction = FakeEndPollAction(),
sendPollResponseAction: SendPollResponseAction = FakeSendPollResponseAction(),
pollHistoryItemFactory: PollHistoryItemsFactory = PollHistoryItemsFactory(
pollContentStateFactory = DefaultPollContentStateFactory(FakeMatrixClient()),
dateFormatter = FakeDateFormatter(),
dispatchers = testCoroutineDispatchers(),
),
): PollHistoryPresenter {
return PollHistoryPresenter(
sessionCoroutineScope = this,
sendPollResponseAction = sendPollResponseAction,
endPollAction = endPollAction,
pollHistoryItemFactory = pollHistoryItemFactory,
room = room,
)
}
@@ -0,0 +1,187 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.history
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.poll.api.pollcontent.aPollContentState
import io.element.android.features.poll.impl.R
import io.element.android.features.poll.impl.history.model.PollHistoryFilter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.pressBack
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
@RunWith(AndroidJUnit4::class)
class PollHistoryViewTest {
@get:Rule
val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PollHistoryEvents>(expectEvents = false)
ensureCalledOnce {
rule.setPollHistoryViewView(
aPollHistoryState(
eventSink = eventsRecorder
),
goBack = it
)
rule.pressBack()
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on edit poll invokes the expected callback`() {
val eventsRecorder = EventsRecorder<PollHistoryEvents>(expectEvents = false)
val eventId = EventId("\$anEventId")
val state = aPollHistoryState(
currentItems = listOf(
aPollHistoryItem(
state = aPollContentState(
eventId = eventId,
isMine = true,
isEnded = false,
)
)
),
eventSink = eventsRecorder
)
ensureCalledOnceWithParam(eventId) {
rule.setPollHistoryViewView(
state = state,
onEditPoll = it
)
rule.clickOn(CommonStrings.action_edit_poll)
}
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on poll end emits the expected Event`() {
val eventsRecorder = EventsRecorder<PollHistoryEvents>()
val eventId = EventId("\$anEventId")
val state = aPollHistoryState(
currentItems = listOf(
aPollHistoryItem(
state = aPollContentState(
eventId = eventId,
isMine = true,
isEnded = false,
isPollEditable = false,
)
)
),
eventSink = eventsRecorder
)
rule.setPollHistoryViewView(
state = state,
)
rule.clickOn(CommonStrings.action_end_poll)
// Cancel the dialog
rule.clickOn(CommonStrings.action_cancel)
// Do it again, and confirm the dialog
rule.clickOn(CommonStrings.action_end_poll)
eventsRecorder.assertEmpty()
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(
PollHistoryEvents.EndPoll(eventId)
)
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on poll answer emits the expected Event`() {
val eventsRecorder = EventsRecorder<PollHistoryEvents>()
val eventId = EventId("\$anEventId")
val state = aPollHistoryState(
currentItems = listOf(
aPollHistoryItem(
state = aPollContentState(
eventId = eventId,
isMine = true,
isEnded = false,
isPollEditable = false,
)
)
),
eventSink = eventsRecorder
)
val answer = state.pollHistoryItems.ongoing.first().state.answerItems.first().answer
rule.setPollHistoryViewView(
state = state,
)
rule.onNodeWithText(
text = answer.text,
useUnmergedTree = true,
).performClick()
eventsRecorder.assertSingle(
PollHistoryEvents.SelectPollAnswer(eventId, answer.id)
)
}
@Test
fun `clicking on past tab emits the expected Event`() {
val eventsRecorder = EventsRecorder<PollHistoryEvents>()
rule.setPollHistoryViewView(
aPollHistoryState(
eventSink = eventsRecorder
),
)
rule.clickOn(R.string.screen_polls_history_filter_past)
eventsRecorder.assertSingle(
PollHistoryEvents.SelectFilter(filter = PollHistoryFilter.PAST)
)
}
@Config(qualifiers = "h1024dp")
@Test
fun `clicking on load more emits the expected Event`() {
val eventsRecorder = EventsRecorder<PollHistoryEvents>()
rule.setPollHistoryViewView(
aPollHistoryState(
hasMoreToLoad = true,
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_load_more)
eventsRecorder.assertSingle(
PollHistoryEvents.LoadMore
)
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setPollHistoryViewView(
state: PollHistoryState,
onEditPoll: (EventId) -> Unit = EnsureNeverCalledWithParam(),
goBack: () -> Unit = EnsureNeverCalled(),
) {
setContent {
PollHistoryView(
state = state,
onEditPoll = onEditPoll,
goBack = goBack,
)
}
}
@@ -0,0 +1,291 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.poll.impl.pollcontent
import com.google.common.truth.Truth.assertThat
import io.element.android.features.poll.api.pollcontent.PollAnswerItem
import io.element.android.features.poll.api.pollcontent.PollContentState
import io.element.android.features.poll.impl.model.DefaultPollContentStateFactory
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
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.timeline.item.event.PollContent
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_ID_10
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_ID_5
import io.element.android.libraries.matrix.test.A_USER_ID_6
import io.element.android.libraries.matrix.test.A_USER_ID_7
import io.element.android.libraries.matrix.test.A_USER_ID_8
import io.element.android.libraries.matrix.test.A_USER_ID_9
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.coroutines.test.runTest
import org.junit.Test
class PollContentStateFactoryTest {
private val factory = DefaultPollContentStateFactory(FakeMatrixClient())
private val eventTimelineItem = anEventTimelineItem()
@Test
fun `Disclosed poll - not ended, no votes`() = runTest {
val state = factory.create(eventTimelineItem, aPollContent())
val expectedState = aPollContentState()
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `Disclosed poll - not ended, some votes, including one from current user`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
val state = factory.create(
eventTimelineItem,
aPollContent(votes = votes)
)
val expectedState = aPollContentState(
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3),
aPollAnswerItem(answer = A_POLL_ANSWER_4, votesCount = 1, percentage = 0.1f),
)
)
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `Disclosed poll - ended, no votes, no winner`() = runTest {
val state = factory.create(eventTimelineItem, aPollContent(endTime = 1UL))
val expectedState = aPollContentState().let {
it.copy(
answerItems = it.answerItems.map { answerItem -> answerItem.copy(isEnabled = false) }.toImmutableList(),
isPollEnded = true,
)
}
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `Disclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
val state = factory.create(
eventTimelineItem,
aPollContent(votes = votes, endTime = 1UL)
)
val expectedState = aPollContentState(
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, isWinner = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f),
),
isEnded = true,
)
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `Disclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
val state = factory.create(
eventTimelineItem,
aPollContent(votes = votes, endTime = 1UL)
)
val expectedState = aPollContentState(
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, votesCount = 2, percentage = 0.2f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
),
isEnded = true,
)
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `Undisclosed poll - not ended, no votes`() = runTest {
val state = factory.create(eventTimelineItem, aPollContent(PollKind.Undisclosed))
val expectedState = aPollContentState(pollKind = PollKind.Undisclosed).let {
it.copy(
answerItems = it.answerItems.map { answerItem -> answerItem.copy(showVotes = false) }.toImmutableList()
)
}
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `Undisclosed poll - not ended, some votes, including one from current user`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
val state = factory.create(
eventTimelineItem,
aPollContent(PollKind.Undisclosed, votes = votes)
)
val expectedState = aPollContentState(
pollKind = PollKind.Undisclosed,
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, showVotes = false, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, showVotes = false, isSelected = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, showVotes = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, showVotes = false, votesCount = 1, percentage = 0.1f),
),
)
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `Undisclosed poll - ended, no votes, no winner`() = runTest {
val state = factory.create(eventTimelineItem, aPollContent(PollKind.Undisclosed, endTime = 1UL))
val expectedState = aPollContentState(
isEnded = true,
pollKind = PollKind.Undisclosed
).let {
it.copy(
answerItems = it.answerItems.map { answerItem -> answerItem.copy(showVotes = true, isEnabled = false) }.toImmutableList(),
)
}
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `Undisclosed poll - ended, some votes, including one from current user (winner)`() = runTest {
val votes = MY_USER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
val state = factory.create(
eventTimelineItem,
aPollContent(PollKind.Undisclosed, votes = votes, endTime = 1UL)
)
val expectedState = aPollContentState(
pollKind = PollKind.Undisclosed,
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, votesCount = 3, percentage = 0.3f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, isWinner = true, votesCount = 6, percentage = 0.6f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f),
),
isEnded = true,
)
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `Undisclosed poll - ended, some votes, including one from current user (not winner) and two winning votes`() = runTest {
val votes = OTHER_WINNING_VOTES.mapKeys { it.key.id }.toImmutableMap()
val state = factory.create(
eventTimelineItem,
aPollContent(PollKind.Undisclosed, votes = votes, endTime = 1UL)
)
val expectedState = aPollContentState(
pollKind = PollKind.Undisclosed,
answerItems = listOf(
aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
aPollAnswerItem(answer = A_POLL_ANSWER_2, isSelected = true, isEnabled = false, votesCount = 2, percentage = 0.2f),
aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false),
aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, isWinner = true, votesCount = 4, percentage = 0.4f),
),
isEnded = true,
)
assertThat(state).isEqualTo(expectedState)
}
@Test
fun `eventId is populated`() = runTest {
val state = factory.create(eventTimelineItem, aPollContent())
assertThat(state.eventId).isEqualTo(eventTimelineItem.eventId)
}
private fun aPollContent(
pollKind: PollKind = PollKind.Disclosed,
votes: ImmutableMap<String, ImmutableList<UserId>> = persistentMapOf(),
endTime: ULong? = null,
): PollContent = PollContent(
question = A_POLL_QUESTION,
kind = pollKind,
maxSelections = 1UL,
answers = persistentListOf(A_POLL_ANSWER_1, A_POLL_ANSWER_2, A_POLL_ANSWER_3, A_POLL_ANSWER_4),
votes = votes,
endTime = endTime,
isEdited = false,
)
private fun aPollContentState(
eventId: EventId? = AN_EVENT_ID,
pollKind: PollKind = PollKind.Disclosed,
answerItems: List<PollAnswerItem> = listOf(
aPollAnswerItem(A_POLL_ANSWER_1),
aPollAnswerItem(A_POLL_ANSWER_2),
aPollAnswerItem(A_POLL_ANSWER_3),
aPollAnswerItem(A_POLL_ANSWER_4),
),
isEnded: Boolean = false,
isMine: Boolean = false,
isEditable: Boolean = false,
question: String = A_POLL_QUESTION,
) = PollContentState(
eventId = eventId,
question = question,
answerItems = answerItems.toImmutableList(),
pollKind = pollKind,
isPollEditable = isEditable,
isPollEnded = isEnded,
isMine = isMine,
)
private fun aPollAnswerItem(
answer: PollAnswer,
isSelected: Boolean = false,
isEnabled: Boolean = true,
isWinner: Boolean = false,
showVotes: Boolean = true,
votesCount: Int = 0,
percentage: Float = 0f,
) = PollAnswerItem(
answer = answer,
isSelected = isSelected,
isEnabled = isEnabled,
isWinner = isWinner,
showVotes = showVotes,
votesCount = votesCount,
percentage = percentage,
)
private companion object TestData {
private const val A_POLL_QUESTION = "What is your favorite food?"
private val A_POLL_ANSWER_1 = PollAnswer("id_1", "Pizza")
private val A_POLL_ANSWER_2 = PollAnswer("id_2", "Pasta")
private val A_POLL_ANSWER_3 = PollAnswer("id_3", "French Fries")
private val A_POLL_ANSWER_4 = PollAnswer("id_4", "Hamburger")
private val MY_USER_WINNING_VOTES = persistentMapOf(
A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4),
// First item (A_USER_ID) is for my vote
// winner
A_POLL_ANSWER_2 to persistentListOf(A_USER_ID, A_USER_ID_5, A_USER_ID_6, A_USER_ID_7, A_USER_ID_8, A_USER_ID_9),
A_POLL_ANSWER_3 to persistentListOf(),
A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_10),
)
private val OTHER_WINNING_VOTES = persistentMapOf(
// A winner
A_POLL_ANSWER_1 to persistentListOf(A_USER_ID_2, A_USER_ID_3, A_USER_ID_4, A_USER_ID_5),
// First item (A_USER_ID) is for my vote
A_POLL_ANSWER_2 to persistentListOf(A_USER_ID, A_USER_ID_6),
A_POLL_ANSWER_3 to persistentListOf(),
// Other winner
A_POLL_ANSWER_4 to persistentListOf(A_USER_ID_7, A_USER_ID_8, A_USER_ID_9, A_USER_ID_10),
)
}
}