From 2cb68ba8110eecd0fae0c13993a395c2028fe487 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:22:31 +0000 Subject: [PATCH 01/14] Max selections support --- .../messages/impl/timeline/TimelineEvent.kt | 2 +- .../impl/timeline/TimelinePresenter.kt | 6 +- .../components/event/TimelineItemPollView.kt | 6 +- .../event/TimelineItemContentPollFactory.kt | 1 + .../model/event/TimelineItemPollContent.kt | 1 + .../event/TimelineItemPollContentProvider.kt | 2 + .../impl/timeline/TimelinePresenterTest.kt | 2 +- .../event/TimelineItemPollViewTest.kt | 2 +- .../api/actions/SendPollResponseAction.kt | 2 +- .../poll/api/pollcontent/PollContentState.kt | 6 +- .../pollcontent/PollContentStateFixtures.kt | 2 + .../poll/api/pollcontent/PollContentView.kt | 67 +++++++++++++++++-- .../features/poll/impl/PollConstants.kt | 2 +- .../actions/DefaultSendPollResponseAction.kt | 4 +- .../poll/impl/create/CreatePollEvent.kt | 1 + .../poll/impl/create/CreatePollPresenter.kt | 9 ++- .../poll/impl/create/CreatePollState.kt | 2 + .../impl/create/CreatePollStateProvider.kt | 20 +++++- .../poll/impl/create/CreatePollView.kt | 63 +++++++++++++++++ .../poll/impl/create/PollFormState.kt | 19 +++++- .../poll/impl/history/PollHistoryEvents.kt | 2 +- .../poll/impl/history/PollHistoryPresenter.kt | 7 +- .../poll/impl/history/PollHistoryView.kt | 8 +-- .../model/DefaultPollContentStateFactory.kt | 9 ++- .../impl/src/main/res/values/temporary.xml | 5 ++ .../impl/create/CreatePollPresenterTest.kt | 2 + .../impl/create/PollFormStateSaverTest.kt | 1 + .../impl/history/PollHistoryPresenterTest.kt | 2 +- .../poll/impl/history/PollHistoryViewTest.kt | 2 +- .../PollContentStateFactoryTest.kt | 13 ++-- .../actions/FakeSendPollResponseAction.kt | 2 +- .../FakePollContentStateFactory.kt | 1 + 32 files changed, 234 insertions(+), 39 deletions(-) create mode 100644 features/poll/impl/src/main/res/values/temporary.xml diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index 1591cbf6cc..74acfe96e3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -47,7 +47,7 @@ sealed interface TimelineEvent { data class SelectPollAnswer( val pollStartId: EventId, - val answerId: String + val answerIds: List ) : TimelineItemPollEvent data class EndPoll( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 12e4e0b1d1..c64ffd7b0c 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -182,8 +182,10 @@ class TimelinePresenter( sendPollResponseAction.execute( timeline = this, pollStartId = event.pollStartId, - answerId = event.answerId - ) + answerIds = event.answerIds + ).onFailure { error -> + Timber.tag(tag).e(error, "Failed to send poll response") + } } } is TimelineEvent.EndPoll -> sessionCoroutineScope.launch { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt index e956a99b45..6b8886c592 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt @@ -26,8 +26,8 @@ fun TimelineItemPollView( eventSink: (TimelineEvent.TimelineItemPollEvent) -> Unit, modifier: Modifier = Modifier, ) { - fun onSelectAnswer(pollStartId: EventId, answerId: String) { - eventSink(TimelineEvent.SelectPollAnswer(pollStartId, answerId)) + fun onSelectAnswer(pollStartId: EventId, answerIds: List) { + eventSink(TimelineEvent.SelectPollAnswer(pollStartId, answerIds)) } fun onEndPoll(pollStartId: EventId) { @@ -43,6 +43,8 @@ fun TimelineItemPollView( question = content.question, answerItems = content.answerItems.toImmutableList(), pollKind = content.pollKind, + isMultipleSelection = content.maxSelections > 1u, + maxSelections = content.maxSelections, isPollEnded = content.isEnded, isPollEditable = content.isEditable, isMine = content.isMine, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt index 2ceb81c487..69c8f16f37 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentPollFactory.kt @@ -33,6 +33,7 @@ class TimelineItemContentPollFactory( question = pollContentState.question, answerItems = pollContentState.answerItems, pollKind = pollContentState.pollKind, + maxSelections = pollContentState.maxSelections, isEnded = pollContentState.isPollEnded, isEdited = content.isEdited ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt index 8ddeb3d041..2e176251ed 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContent.kt @@ -19,6 +19,7 @@ data class TimelineItemPollContent( val question: String, val answerItems: List, val pollKind: PollKind, + val maxSelections: ULong, val isEnded: Boolean, override val isEdited: Boolean, ) : TimelineItemEventContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt index 26577f5230..040a3cd7fe 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemPollContentProvider.kt @@ -32,12 +32,14 @@ fun aTimelineItemPollContent( isEditable: Boolean = false, isEnded: Boolean = false, isEdited: Boolean = false, + maxSelections: ULong = 1u, ): TimelineItemPollContent { return TimelineItemPollContent( eventId = EventId("\$anEventId"), pollKind = PollKind.Disclosed, question = question, answerItems = answerItems, + maxSelections = maxSelections, isMine = isMine, isEditable = isEditable, isEnded = isEnded, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 194694714b..176dcca219 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -428,7 +428,7 @@ class TimelinePresenterTest { ) presenter.test { val initialState = awaitFirstItem() - initialState.eventSink.invoke(TimelineEvent.SelectPollAnswer(AN_EVENT_ID, "anAnswerId")) + initialState.eventSink.invoke(TimelineEvent.SelectPollAnswer(AN_EVENT_ID, listOf("anAnswerId"))) } delay(1) sendPollResponseAction.verifyExecutionCount(1) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt index 64b5216d2e..b6c81b2825 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt @@ -52,7 +52,7 @@ class TimelineItemPollViewTest { matcher = hasText(answer.text), useUnmergedTree = true, ).performClick() - eventsRecorder.assertSingle(TimelineEvent.SelectPollAnswer(content.eventId!!, answer.id)) + eventsRecorder.assertSingle(TimelineEvent.SelectPollAnswer(content.eventId!!, listOf(answer.id))) } @Test diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt index 00b60ed5ae..ac70718c57 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt @@ -15,6 +15,6 @@ interface SendPollResponseAction { suspend fun execute( timeline: Timeline, pollStartId: EventId, - answerId: String + answerIds: List ): Result } diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt index 1689408098..ecadc37a23 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt @@ -18,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableList * @property question the poll question. * @property answerItems the list of answers. * @property pollKind the kind of poll. + * @property maxSelections the maximum number of selections allowed. * @property isPollEditable whether the poll is editable. * @property isPollEnded whether the poll is ended. * @property isMine whether the poll has been created by me. @@ -27,7 +28,10 @@ data class PollContentState( val question: String, val answerItems: ImmutableList, val pollKind: PollKind, + val maxSelections: ULong, val isPollEditable: Boolean, val isPollEnded: Boolean, val isMine: Boolean, -) +) { + val isMultipleSelection: Boolean get() = maxSelections > 1u +} diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt index 7f15c7d513..57db5672f9 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentStateFixtures.kt @@ -84,6 +84,7 @@ fun aPollContentState( hasVotes: Boolean = true, question: String = aPollQuestion(), pollKind: PollKind = PollKind.Disclosed, + maxSelections: ULong = 1u, answerItems: ImmutableList = aPollAnswerItemList( isEnded = isEnded, showVotes = showVotes, @@ -94,6 +95,7 @@ fun aPollContentState( question = question, answerItems = answerItems, pollKind = pollKind, + maxSelections = maxSelections, isPollEditable = isMine && !isEnded && isPollEditable, isPollEnded = isEnded, isMine = isMine, diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt index e1df373218..f5078ecd50 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt @@ -40,7 +40,7 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun PollContentView( state: PollContentState, - onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onSelectAnswer: (pollStartId: EventId, answerIds: List) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, @@ -50,6 +50,8 @@ fun PollContentView( question = state.question, answerItems = state.answerItems, pollKind = state.pollKind, + isMultipleSelection = state.isMultipleSelection, + maxSelections = state.maxSelections, isPollEditable = state.isPollEditable, isPollEnded = state.isPollEnded, isMine = state.isMine, @@ -66,10 +68,12 @@ fun PollContentView( question: String, answerItems: ImmutableList, pollKind: PollKind, + isMultipleSelection: Boolean, + maxSelections: ULong, isPollEditable: Boolean, isPollEnded: Boolean, isMine: Boolean, - onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onSelectAnswer: (pollStartId: EventId, answerIds: List) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, @@ -77,7 +81,23 @@ fun PollContentView( val votesCount = remember(answerItems) { answerItems.sumOf { it.votesCount } } fun onSelectAnswer(pollAnswer: PollAnswer) { - eventId?.let { onSelectAnswer(it, pollAnswer.id) } + eventId?.let { + if (isMultipleSelection) { + val newSelections = answerItems.map { item -> + if (item.answer.id == pollAnswer.id) { + item.copy(isSelected = !item.isSelected) + } else { + item + } + }.filter { it.isSelected }.map { it.answer.id } + // Only send the response if the selection count is within the limit + if (newSelections.size <= maxSelections.toInt()) { + onSelectAnswer(it, newSelections) + } + } else { + onSelectAnswer(it, listOf(pollAnswer.id)) + } + } } fun onEditPoll() { @@ -107,7 +127,11 @@ fun PollContentView( ) { PollTitleView(title = question, isPollEnded = isPollEnded) - PollAnswers(answerItems = answerItems, onSelectAnswer = ::onSelectAnswer) + PollAnswers( + answerItems = answerItems, + isMultipleSelection = isMultipleSelection, + onSelectAnswer = ::onSelectAnswer, + ) if (isPollEnded || pollKind == PollKind.Disclosed) { DisclosedPollBottomNotice(votesCount = votesCount) @@ -130,8 +154,10 @@ fun PollContentView( @Composable private fun PollAnswers( answerItems: ImmutableList, + isMultipleSelection: Boolean, onSelectAnswer: (PollAnswer) -> Unit, ) { + val role = if (isMultipleSelection) Role.Checkbox else Role.RadioButton Column( modifier = Modifier.selectableGroup(), verticalArrangement = Arrangement.spacedBy(16.dp), @@ -144,7 +170,7 @@ private fun PollAnswers( selected = it.isSelected, enabled = it.isEnabled, onClick = { onSelectAnswer(it.answer) }, - role = Role.RadioButton, + role = role, ), ) } @@ -207,6 +233,8 @@ internal fun PollContentViewUndisclosedPreview() = ElementPreview { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(showVotes = false), pollKind = PollKind.Undisclosed, + isMultipleSelection = false, + maxSelections = 1u, isPollEnded = false, isPollEditable = false, isMine = false, @@ -224,6 +252,27 @@ internal fun PollContentViewDisclosedPreview() = ElementPreview { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(), pollKind = PollKind.Disclosed, + isMultipleSelection = false, + maxSelections = 1u, + isPollEnded = false, + isPollEditable = false, + isMine = false, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, + ) +} + +@PreviewsDayNight +@Composable +internal fun PollContentViewMultipleSelectionPreview() = ElementPreview { + PollContentView( + eventId = EventId("\$anEventId"), + question = "What type of food should we have at the party?", + answerItems = aPollAnswerItemList(), + pollKind = PollKind.Disclosed, + isMultipleSelection = true, + maxSelections = 3u, isPollEnded = false, isPollEditable = false, isMine = false, @@ -241,6 +290,8 @@ internal fun PollContentViewEndedPreview() = ElementPreview { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(isEnded = true), pollKind = PollKind.Disclosed, + isMultipleSelection = false, + maxSelections = 1u, isPollEnded = true, isPollEditable = false, isMine = false, @@ -258,6 +309,8 @@ internal fun PollContentViewCreatorEditablePreview() = ElementPreview { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(hasVotes = false, isEnded = false), pollKind = PollKind.Disclosed, + isMultipleSelection = false, + maxSelections = 1u, isPollEnded = false, isPollEditable = true, isMine = true, @@ -275,6 +328,8 @@ internal fun PollContentViewCreatorPreview() = ElementPreview { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(isEnded = false), pollKind = PollKind.Disclosed, + isMultipleSelection = false, + maxSelections = 1u, isPollEnded = false, isPollEditable = false, isMine = true, @@ -292,6 +347,8 @@ internal fun PollContentViewCreatorEndedPreview() = ElementPreview { question = "What type of food should we have at the party?", answerItems = aPollAnswerItemList(isEnded = true), pollKind = PollKind.Disclosed, + isMultipleSelection = false, + maxSelections = 1u, isPollEnded = true, isPollEditable = false, isMine = true, diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollConstants.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollConstants.kt index c3579dc447..7c6eab370b 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollConstants.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/PollConstants.kt @@ -12,5 +12,5 @@ internal object PollConstants { const val MIN_ANSWERS = 2 const val MAX_ANSWERS = 20 const val MAX_ANSWER_LENGTH = 240 - const val MAX_SELECTIONS = 1 + const val DEFAULT_MAX_SELECTIONS = 1 } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt index f48604b21d..b872f9fb27 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/actions/DefaultSendPollResponseAction.kt @@ -20,10 +20,10 @@ import io.element.android.services.analytics.api.AnalyticsService class DefaultSendPollResponseAction( private val analyticsService: AnalyticsService, ) : SendPollResponseAction { - override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result { + override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerIds: List): Result { return timeline.sendPollResponse( pollStartId = pollStartId, - answers = listOf(answerId), + answers = answerIds, ).onSuccess { analyticsService.capture(PollVote()) } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt index b98ab899b9..2d686d3d8d 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt @@ -18,6 +18,7 @@ sealed interface CreatePollEvent { data object AddAnswer : CreatePollEvent data class RemoveAnswer(val index: Int) : CreatePollEvent data class SetPollKind(val pollKind: PollKind) : CreatePollEvent + data class SetMaxSelections(val maxSelections: Int) : CreatePollEvent data object NavBack : CreatePollEvent data object ConfirmNavBack : CreatePollEvent data object HideConfirmation : CreatePollEvent diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt index 8e01f1a3e4..22d096e763 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt @@ -24,7 +24,6 @@ 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 @@ -81,6 +80,7 @@ class CreatePollPresenter( question = it.question, answers = it.answers.map(PollAnswer::text).toImmutableList(), isDisclosed = it.kind.isDisclosed, + maxSelections = it.maxSelections.toInt(), ) initialPoll = loadedPoll poll = loadedPoll @@ -109,7 +109,7 @@ class CreatePollPresenter( question = poll.question, answers = poll.answers, pollKind = poll.pollKind, - maxSelections = MAX_SELECTIONS, + maxSelections = poll.effectiveMaxSelections, ).onSuccess { analyticsService.capturePollSaved( isUndisclosed = poll.pollKind == PollKind.Undisclosed, @@ -151,6 +151,9 @@ class CreatePollPresenter( is CreatePollEvent.SetPollKind -> { poll = poll.copy(isDisclosed = event.pollKind.isDisclosed) } + is CreatePollEvent.SetMaxSelections -> { + poll = poll.copy(maxSelections = event.maxSelections) + } is CreatePollEvent.SetQuestion -> { poll = poll.copy(question = event.question) } @@ -182,6 +185,8 @@ class CreatePollPresenter( question = poll.question, answers = immutableAnswers, pollKind = poll.pollKind, + maxSelections = poll.effectiveMaxSelections, + maxAllowedSelections = poll.answers.size, showBackConfirmation = showBackConfirmation, showDeleteConfirmation = showDeleteConfirmation, eventSink = ::handleEvent, diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt index 80aa7dc4b0..20f4beaf3e 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt @@ -18,6 +18,8 @@ data class CreatePollState( val question: String, val answers: ImmutableList, val pollKind: PollKind, + val maxSelections: Int, + val maxAllowedSelections: Int, val showBackConfirmation: Boolean, val showDeleteConfirmation: Boolean, val eventSink: (CreatePollEvent) -> Unit, diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStateProvider.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStateProvider.kt index 20d9f0d557..13c1ca0a50 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStateProvider.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollStateProvider.kt @@ -150,6 +150,21 @@ class CreatePollStateProvider : PreviewParameterProvider { showDeleteConfirmation = true, showBackConfirmation = 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), + Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true), + ), + showBackConfirmation = false, + showDeleteConfirmation = false, + pollKind = PollKind.Disclosed, + maxSelections = 2, + ), ) } @@ -161,7 +176,8 @@ private fun aCreatePollState( answers: List, showBackConfirmation: Boolean, showDeleteConfirmation: Boolean, - pollKind: PollKind + pollKind: PollKind, + maxSelections: Int = 1, ): CreatePollState { return CreatePollState( mode = mode, @@ -172,6 +188,8 @@ private fun aCreatePollState( showBackConfirmation = showBackConfirmation, showDeleteConfirmation = showDeleteConfirmation, pollKind = pollKind, + maxSelections = maxSelections, + maxAllowedSelections = answers.size, eventSink = {} ) } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt index 3abf3718af..b316563f02 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt @@ -11,11 +11,13 @@ 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.Row 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.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -25,6 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -32,6 +35,8 @@ 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 androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme 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 @@ -173,6 +178,18 @@ fun CreatePollView( item { Column { HorizontalDivider() + ListItem( + headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_max_selections_headline)) }, + supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_max_selections_desc)) }, + trailingContent = ListItemContent.Custom { + MaxSelectionsStepper( + current = state.maxSelections, + max = state.maxAllowedSelections, + onDecrement = { state.eventSink(CreatePollEvent.SetMaxSelections(state.maxSelections - 1)) }, + onIncrement = { state.eventSink(CreatePollEvent.SetMaxSelections(state.maxSelections + 1)) }, + ) + }, + ) 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)) }, @@ -229,6 +246,52 @@ private fun CreatePollTopAppBar( ) } +@Composable +private fun MaxSelectionsStepper( + current: Int, + max: Int, + onDecrement: () -> Unit, + onIncrement: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CompoundIcons.Minus(), + contentDescription = null, + modifier = Modifier + .clickable(enabled = current > 1) { onDecrement() } + .size(32.dp) + .padding(4.dp), + tint = if (current > 1) { + ElementTheme.colors.bgAccentRest + } else { + ElementTheme.colors.iconDisabled + }, + ) + Text( + text = current.toString(), + modifier = Modifier.padding(horizontal = 12.dp), + style = ElementTheme.typography.fontBodyLgRegular, + ) + Icon( + imageVector = CompoundIcons.Plus(), + contentDescription = null, + modifier = Modifier + .clickable(enabled = current < max) { onIncrement() } + .size(32.dp) + .padding(4.dp), + tint = if (current < max) { + ElementTheme.colors.bgAccentRest + } else { + ElementTheme.colors.iconDisabled + }, + ) + } +} + @PreviewsDayNight @Composable internal fun CreatePollViewPreview( diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt index 46045d711d..eae39a670d 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt @@ -25,12 +25,14 @@ data class PollFormState( val question: String, val answers: ImmutableList, val isDisclosed: Boolean, + val maxSelections: Int, ) { companion object { val Empty = PollFormState( question = "", answers = MutableList(MIN_ANSWERS) { "" }.toImmutableList(), isDisclosed = true, + maxSelections = 1, ) } @@ -44,13 +46,17 @@ data class PollFormState( * 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. + * + * Also clamps [maxSelections] to the new answer count to keep state consistent. */ fun withNewAnswer(): PollFormState { if (!canAddAnswer) { return this } - return copy(answers = (answers + "").toImmutableList()) + val newAnswers = (answers + "").toImmutableList() + val clampedMaxSelections = maxSelections.coerceIn(1, newAnswers.size) + return copy(answers = newAnswers, maxSelections = clampedMaxSelections) } /** @@ -67,7 +73,9 @@ data class PollFormState( return this } - return copy(answers = answers.filterIndexed { i, _ -> i != index }.toImmutableList()) + val newAnswers = answers.filterIndexed { i, _ -> i != index }.toImmutableList() + val clampedMaxSelections = maxSelections.coerceIn(1, newAnswers.size) + return copy(answers = newAnswers, maxSelections = clampedMaxSelections) } /** @@ -95,6 +103,11 @@ data class PollFormState( */ val canDeleteAnswer get() = answers.size > MIN_ANSWERS + /** + * The effective max selections, clamped to the number of answers. + */ + val effectiveMaxSelections get() = maxSelections.coerceIn(1, answers.size) + /** * Whether the form is currently valid. */ @@ -110,6 +123,7 @@ internal val pollFormStateSaver = mapSaver( "question" to it.question, "answers" to it.answers.toTypedArray(), "isDisclosed" to it.isDisclosed, + "maxSelections" to it.maxSelections, ) }, restore = { saved -> @@ -117,6 +131,7 @@ internal val pollFormStateSaver = mapSaver( question = saved["question"] as String, answers = (saved["answers"] as Array<*>).map { it as String }.toImmutableList(), isDisclosed = saved["isDisclosed"] as Boolean, + maxSelections = saved["maxSelections"] as Int, ) } ) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt index 14ce13d67a..0ecd9dde16 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt @@ -13,7 +13,7 @@ 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 SelectPollAnswer(val pollStartId: EventId, val answerIds: List) : PollHistoryEvents data class EndPoll(val pollStartId: EventId) : PollHistoryEvents data class SelectFilter(val filter: PollHistoryFilter) : PollHistoryEvents } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt index 434e2ed18c..08a0a17b60 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import timber.log.Timber @Inject class PollHistoryPresenter( @@ -72,8 +73,10 @@ class PollHistoryPresenter( sendPollResponseAction.execute( timeline = timeline, pollStartId = event.pollStartId, - answerId = event.answerId - ) + answerIds = event.answerIds + ).onFailure { error -> + Timber.e(error, "Failed to send poll response") + } } is PollHistoryEvents.EndPoll -> sessionCoroutineScope.launch { endPollAction.execute(timeline = timeline, pollStartId = event.pollStartId) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt index 9a654c2abb..0210f1efcb 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt @@ -65,8 +65,8 @@ fun PollHistoryView( state.eventSink(PollHistoryEvents.LoadMore) } - fun onSelectAnswer(pollStartId: EventId, answerId: String) { - state.eventSink(PollHistoryEvents.SelectPollAnswer(pollStartId, answerId)) + fun onSelectAnswer(pollStartId: EventId, answerIds: List) { + state.eventSink(PollHistoryEvents.SelectPollAnswer(pollStartId, answerIds)) } fun onEndPoll(pollStartId: EventId) { @@ -151,7 +151,7 @@ private fun PollHistoryList( pollHistoryItems: ImmutableList, hasMoreToLoad: Boolean, isLoading: Boolean, - onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onSelectAnswer: (pollStartId: EventId, answerIds: List) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit, onLoadMore: () -> Unit, @@ -222,7 +222,7 @@ private fun LoadMoreButton(isLoading: Boolean, onClick: () -> Unit) { @Composable private fun PollHistoryItemRow( pollHistoryItem: PollHistoryItem, - onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onSelectAnswer: (pollStartId: EventId, answerIds: List) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt index f781b9474d..16ac2415fa 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/model/DefaultPollContentStateFactory.kt @@ -43,15 +43,21 @@ class DefaultPollContentStateFactory( ?.value .orEmpty() } + val currentSelectionCount = myVotes.size + val maxSelections = content.maxSelections.toInt() + val isSelectionLimitReached = currentSelectionCount >= maxSelections + 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 + // An answer is enabled if the poll hasn't ended and either the user can still select more answers or this answer is already selected + val isEnabled = !isPollEnded && (!isSelectionLimitReached || isSelected) PollAnswerItem( answer = answer, isSelected = isSelected, - isEnabled = !isPollEnded, + isEnabled = isEnabled, isWinner = isWinner, showVotes = content.kind.isDisclosed || isPollEnded, votesCount = answerVoteCount, @@ -64,6 +70,7 @@ class DefaultPollContentStateFactory( question = content.question, answerItems = answerItems.toImmutableList(), pollKind = content.kind, + maxSelections = content.maxSelections, isPollEditable = isEditable, isPollEnded = isPollEnded, isMine = isOwn, diff --git a/features/poll/impl/src/main/res/values/temporary.xml b/features/poll/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..8e8935a1e0 --- /dev/null +++ b/features/poll/impl/src/main/res/values/temporary.xml @@ -0,0 +1,5 @@ + + + "Maximum selections" + "How many options each voter can select" + diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt index dee0268c1c..61190e20a7 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt @@ -525,6 +525,8 @@ class CreatePollPresenterTest { assertThat(question).isEmpty() assertThat(answers).isEqualTo(listOf(Answer("", false), Answer("", false))) assertThat(pollKind).isEqualTo(PollKind.Disclosed) + assertThat(maxSelections).isEqualTo(1) + assertThat(maxAllowedSelections).isEqualTo(2) assertThat(showBackConfirmation).isFalse() assertThat(showDeleteConfirmation).isFalse() } diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateSaverTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateSaverTest.kt index 86e4b28676..ba46d77a26 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateSaverTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/PollFormStateSaverTest.kt @@ -24,6 +24,7 @@ class PollFormStateSaverTest { question = "question", answers = persistentListOf("answer1", "answer2"), isDisclosed = true, + maxSelections = 2, ) val saved = with(CanSaveScope) { diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt index 8d4cedc6a8..537dcf7604 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt @@ -117,7 +117,7 @@ class PollHistoryPresenterTest { state.eventSink(PollHistoryEvents.EndPoll(AN_EVENT_ID)) runCurrent() endPollAction.verifyExecutionCount(1) - state.eventSink(PollHistoryEvents.SelectPollAnswer(AN_EVENT_ID, "answer")) + state.eventSink(PollHistoryEvents.SelectPollAnswer(AN_EVENT_ID, listOf("answer"))) runCurrent() sendPollResponseAction.verifyExecutionCount(1) cancelAndConsumeRemainingEvents() diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt index 1ff25a0a81..3614b06ad8 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt @@ -137,7 +137,7 @@ class PollHistoryViewTest { useUnmergedTree = true, ).performClick() eventsRecorder.assertSingle( - PollHistoryEvents.SelectPollAnswer(eventId, answer.id) + PollHistoryEvents.SelectPollAnswer(eventId, listOf(answer.id)) ) } diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt index 277508681e..5996b29835 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt @@ -59,10 +59,10 @@ class PollContentStateFactoryTest { ) val expectedState = aPollContentState( answerItems = listOf( - aPollAnswerItem(answer = A_POLL_ANSWER_1, votesCount = 3, percentage = 0.3f), + aPollAnswerItem(answer = A_POLL_ANSWER_1, isEnabled = false, 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), + aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, votesCount = 1, percentage = 0.1f), ) ) assertThat(state).isEqualTo(expectedState) @@ -139,10 +139,10 @@ class PollContentStateFactoryTest { 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_1, isEnabled = false, 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), + aPollAnswerItem(answer = A_POLL_ANSWER_3, isEnabled = false, showVotes = false), + aPollAnswerItem(answer = A_POLL_ANSWER_4, isEnabled = false, showVotes = false, votesCount = 1, percentage = 0.1f), ), ) assertThat(state).isEqualTo(expectedState) @@ -241,6 +241,7 @@ class PollContentStateFactoryTest { question = question, answerItems = answerItems.toImmutableList(), pollKind = pollKind, + maxSelections = 1u, isPollEditable = isEditable, isPollEnded = isEnded, isMine = isMine, diff --git a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt index 1a779d5cab..e188dc1e4f 100644 --- a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt +++ b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/actions/FakeSendPollResponseAction.kt @@ -19,7 +19,7 @@ class FakeSendPollResponseAction : SendPollResponseAction { assert(executionCount == count) } - override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerId: String): Result { + override suspend fun execute(timeline: Timeline, pollStartId: EventId, answerIds: List): Result { executionCount++ return Result.success(Unit) } diff --git a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/pollcontent/FakePollContentStateFactory.kt b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/pollcontent/FakePollContentStateFactory.kt index 87755afaf0..4a3bfc2041 100644 --- a/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/pollcontent/FakePollContentStateFactory.kt +++ b/features/poll/test/src/main/kotlin/io/element/android/features/poll/test/pollcontent/FakePollContentStateFactory.kt @@ -22,6 +22,7 @@ class FakePollContentStateFactory : PollContentStateFactory { question = content.question, answerItems = emptyList().toImmutableList(), pollKind = content.kind, + maxSelections = content.maxSelections, isPollEditable = isEditable, isPollEnded = content.endTime != null, isMine = isOwn, From a3fb65e924447bb00aff2a53e4d6fefc6d8fc170 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:00:18 +0000 Subject: [PATCH 02/14] Address design --- .../android/features/poll/impl/create/CreatePollView.kt | 1 - features/poll/impl/src/main/res/values/temporary.xml | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt index b316563f02..1ba8044566 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt @@ -180,7 +180,6 @@ fun CreatePollView( HorizontalDivider() ListItem( headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_max_selections_headline)) }, - supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_max_selections_desc)) }, trailingContent = ListItemContent.Custom { MaxSelectionsStepper( current = state.maxSelections, diff --git a/features/poll/impl/src/main/res/values/temporary.xml b/features/poll/impl/src/main/res/values/temporary.xml index 8e8935a1e0..8292270d45 100644 --- a/features/poll/impl/src/main/res/values/temporary.xml +++ b/features/poll/impl/src/main/res/values/temporary.xml @@ -1,5 +1,4 @@ - "Maximum selections" - "How many options each voter can select" + "Votes allowed per person" From f067ba34e77ca9770ab0ec47f171c0a9e0b4c08d Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:04:37 +0300 Subject: [PATCH 03/14] Update features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt Co-authored-by: Benoit Marty --- .../android/features/poll/api/pollcontent/PollContentState.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt index ecadc37a23..796ac179d9 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentState.kt @@ -33,5 +33,5 @@ data class PollContentState( val isPollEnded: Boolean, val isMine: Boolean, ) { - val isMultipleSelection: Boolean get() = maxSelections > 1u + val isMultipleSelection = maxSelections > 1u } From a04078aa9d8fe0e7d51bc4245891177a8a139957 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:38:20 +0000 Subject: [PATCH 04/14] Address review --- .../features/poll/impl/create/CreatePollView.kt | 4 ++-- .../android/features/poll/impl/create/PollFormState.kt | 10 +--------- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt index ea234f1b4d..9800af5b88 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollView.kt @@ -259,7 +259,7 @@ private fun MaxSelectionsStepper( ) { Icon( imageVector = CompoundIcons.Minus(), - contentDescription = null, + contentDescription = stringResource(id = CommonStrings.a11y_create_poll_votes_allowed_decrease), modifier = Modifier .clickable(enabled = current > 1) { onDecrement() } .size(32.dp) @@ -277,7 +277,7 @@ private fun MaxSelectionsStepper( ) Icon( imageVector = CompoundIcons.Plus(), - contentDescription = null, + contentDescription = stringResource(id = CommonStrings.a11y_create_poll_votes_allowed_increase), modifier = Modifier .clickable(enabled = current < max) { onIncrement() } .size(32.dp) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt index eae39a670d..d7198b8c3d 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt @@ -46,8 +46,6 @@ data class PollFormState( * 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. - * - * Also clamps [maxSelections] to the new answer count to keep state consistent. */ fun withNewAnswer(): PollFormState { if (!canAddAnswer) { @@ -55,8 +53,7 @@ data class PollFormState( } val newAnswers = (answers + "").toImmutableList() - val clampedMaxSelections = maxSelections.coerceIn(1, newAnswers.size) - return copy(answers = newAnswers, maxSelections = clampedMaxSelections) + return copy(answers = newAnswers) } /** @@ -103,11 +100,6 @@ data class PollFormState( */ val canDeleteAnswer get() = answers.size > MIN_ANSWERS - /** - * The effective max selections, clamped to the number of answers. - */ - val effectiveMaxSelections get() = maxSelections.coerceIn(1, answers.size) - /** * Whether the form is currently valid. */ From 221f13269e0c82c25700354ef98a663e7d042dc7 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:42:16 +0000 Subject: [PATCH 05/14] Fix --- .../element/android/features/poll/impl/create/PollFormState.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt index d7198b8c3d..ef70ac4ae6 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/PollFormState.kt @@ -52,8 +52,7 @@ data class PollFormState( return this } - val newAnswers = (answers + "").toImmutableList() - return copy(answers = newAnswers) + return copy(answers = (answers + "").toImmutableList()) } /** From d36daca2420e9835df9dd798765636e2a03359e1 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:44:43 +0000 Subject: [PATCH 06/14] Fix --- .../android/features/poll/impl/create/CreatePollPresenter.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt index 22d096e763..b2c5bc1276 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt @@ -109,7 +109,7 @@ class CreatePollPresenter( question = poll.question, answers = poll.answers, pollKind = poll.pollKind, - maxSelections = poll.effectiveMaxSelections, + maxSelections = poll.maxSelections, ).onSuccess { analyticsService.capturePollSaved( isUndisclosed = poll.pollKind == PollKind.Undisclosed, @@ -185,7 +185,7 @@ class CreatePollPresenter( question = poll.question, answers = immutableAnswers, pollKind = poll.pollKind, - maxSelections = poll.effectiveMaxSelections, + maxSelections = poll.maxSelections, maxAllowedSelections = poll.answers.size, showBackConfirmation = showBackConfirmation, showDeleteConfirmation = showDeleteConfirmation, From 0b0124708dd64aec1d55ae1f046484d780065010 Mon Sep 17 00:00:00 2001 From: bxdxnn <267911624+bxdxnn@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:05:32 +0000 Subject: [PATCH 07/14] Add the new test to the list --- .../io/element/android/tests/konsist/KonsistPreviewTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index e2a387eede..12b46b1207 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -124,6 +124,7 @@ class KonsistPreviewTest { "PollContentViewCreatorPreview", "PollContentViewDisclosedPreview", "PollContentViewEndedPreview", + "PollContentViewMultipleSelectionPreview", "PollContentViewUndisclosedPreview", "ProgressDialogWithContentPreview", "ProgressDialogWithTextAndContentPreview", From 2c4cf090cbadba051aa937dd52e7fc2d2788d2d5 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Fri, 14 Aug 2026 18:27:41 +0000 Subject: [PATCH 08/14] Update screenshots --- ....pollcontent_PollContentViewMultipleSelection_Day_0_en.png | 3 +++ ...ollcontent_PollContentViewMultipleSelection_Night_0_en.png | 3 +++ .../features.poll.impl.create_CreatePollView_Day_0_en.png | 4 ++-- .../features.poll.impl.create_CreatePollView_Day_1_en.png | 4 ++-- .../features.poll.impl.create_CreatePollView_Day_2_en.png | 4 ++-- .../features.poll.impl.create_CreatePollView_Day_3_en.png | 4 ++-- .../features.poll.impl.create_CreatePollView_Day_6_en.png | 4 ++-- .../features.poll.impl.create_CreatePollView_Day_7_en.png | 4 ++-- .../features.poll.impl.create_CreatePollView_Day_8_en.png | 3 +++ .../features.poll.impl.create_CreatePollView_Night_0_en.png | 4 ++-- .../features.poll.impl.create_CreatePollView_Night_1_en.png | 4 ++-- .../features.poll.impl.create_CreatePollView_Night_2_en.png | 4 ++-- .../features.poll.impl.create_CreatePollView_Night_3_en.png | 4 ++-- .../features.poll.impl.create_CreatePollView_Night_6_en.png | 4 ++-- .../features.poll.impl.create_CreatePollView_Night_7_en.png | 4 ++-- .../features.poll.impl.create_CreatePollView_Night_8_en.png | 3 +++ 16 files changed, 36 insertions(+), 24 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_8_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Day_0_en.png new file mode 100644 index 0000000000..276c82c9ec --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35741e180e6c07b452c485099c11473e80ac308ae81d3620fcd8e3bd1385f977 +size 46801 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Night_0_en.png new file mode 100644 index 0000000000..85edbd5662 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.poll.api.pollcontent_PollContentViewMultipleSelection_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b64a00c01448b8935985343ae33912c80dd84ea2329717b8addfc07dd6ef012 +size 45403 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_0_en.png index 671ccdaf1d..eaa6aa90ad 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d71e5d6bdf78f145e573181ba8fe5faccf8fc8121d1b0318affc786222166bdd -size 31928 +oid sha256:af008ab901ea99aa9746625a30e1e6635aa471e12de5e80e6b1b0edd4bbd227f +size 36663 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_1_en.png index 8d7a518c24..d0af439fcc 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e4d08ca16ac4ebe85c49387264490c076aa95ed0aa15f467acd0f6d07fa6fc01 -size 36340 +oid sha256:f09703cfe6146050c3828022ad0038e9538d4d57f2de3fb4dbc25f9d1c16e2c9 +size 41135 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_2_en.png index 19847bbdc6..511b3e1e8c 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:30ff43a32b34192d49dbe9763fe7649c8fa5723c7f2b9ae6ed1531209d986378 -size 39885 +oid sha256:37758cba9b8f6e925d49f7800643f2a5be1ea3a812b5af49b88679675805af52 +size 42145 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_3_en.png index c9a0b43e73..dcae3c5d73 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9a5efead7d85b19215a64ccdc9c1f6ddf66558a27358eb197aeae1aebbac71a2 -size 44019 +oid sha256:44ba43c5a994442b16b185918d7132a4588fb16c374025574990ae1c9f5ea8de +size 48877 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_6_en.png index ea57b81992..189e2a834d 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3c2b6cae066cb50c3916a08009041d7b2a3335baa8e26e8a29d6bda75818000c -size 32650 +oid sha256:c7c9e5e1c89cc3f8be713c4e54f920465a7bde4eddf5516c8c61f3f1ea6da02f +size 37178 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_7_en.png index b025ff46e1..8e285a9f4e 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f3c20f5f523433f9d750736f006780d943beb47622190bd91980391f2a0ec55d -size 35996 +oid sha256:1d2051f52b7c19bc89fccf03f7df704977d4a57d3d675462f24a3c8a0355718b +size 38311 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_8_en.png new file mode 100644 index 0000000000..0d37c1465f --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:935d1fcc6281c464da058256c2263e5524dd17d3cb8e6a54e3bed0ab545219bc +size 46248 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_0_en.png index 5833854bb3..3d1721d102 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf48171815e7af7568af42d81c861cd6d66f413f16f76afbb5f472828ecf826b -size 31053 +oid sha256:c4bb28055f351d3086cb211e058d03067b60de339eec9fecce4c3e74e41ecba0 +size 35636 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_1_en.png index 1052f840ff..6746f98bd0 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8f38b81f5df1f85f449bc22bd52d19f8dfda7413fd50a880dcf87af6b668a00b -size 35195 +oid sha256:82291f1f901e2f4833d6653b2b706fea02b41cd337351ecf990f887d4c2c4dfe +size 39782 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_2_en.png index 0dd50a54c3..f256c57b2b 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e7f6f50bb7b1621f01f21cfde24961080127715be540435a594c356712f10501 -size 37658 +oid sha256:2cc022276e6cecce39bc96f57f03c97e29d42fb94a0645b6b75921d29e6dc5de +size 40067 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_3_en.png index d07a35f70b..da856dc91d 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f3557e5598202ef6d965405a4711bd93f4b7fbee5585b5cd3d4ba8f4307b1a4 -size 42713 +oid sha256:35e6e1cfea95da68f9bd0b3f5616cea73ea8283a1749cab3c3353635ffce56b9 +size 47612 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_6_en.png index 69a97d0dbb..61ce31e648 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_6_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_6_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fbf59e6846ba24941ae9278abf6fa80af208ca5eaea2bd2b65b3c9954f11992 -size 31662 +oid sha256:1f41fd5590ad4fe368091b0522a94b1029982e4d74a8596b3550b6569cc70790 +size 36124 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_7_en.png index 93a9905735..f1631ddf29 100644 --- a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9954304cc948652c3c63378ac77aeaba623398b72eab6bb90d23b15850fe278c -size 33522 +oid sha256:9432d0011ca454e716717a3c8a0daa72b2bddde3add15686e2470cb61081a3e2 +size 36008 diff --git a/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_8_en.png new file mode 100644 index 0000000000..17dd841646 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.poll.impl.create_CreatePollView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e65180faa17fa1914f77f4e6cb445e83c383c3de964e66963ebbdf8d116d6888 +size 44818 From 6b2e83cb6331644ac4f725d151aabb11cf3ce869 Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:03:42 +0300 Subject: [PATCH 09/14] Fall back to the message body when the formatted body has nothing to render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTML sanitiser in toHtmlDocument() drops every tag outside its safelist, which does not include img. A formatted body whose only content is an image — a custom emoji, or a bot posting an image inline — therefore cleans down to an empty document, and because that document is not null the ?: body fallbacks in the timeline item factory, the room list preview, the notification resolver and plainText are all dead. The message renders as an empty bubble. Return null from toHtmlDocument() when no text survives the sanitisation, so the existing fallbacks take the plain text body, which for these messages carries the alt text the sender provided. --- .../TimelineItemContentMessageFactoryTest.kt | 19 +++++++++++++++ .../matrix/ui/messages/ToHtmlDocument.kt | 5 ++-- .../matrix/ui/messages/ToHtmlDocumentTest.kt | 24 +++++++++++++++++++ .../matrix/ui/messages/ToPlainTextTest.kt | 12 ++++++++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index fc01184764..c014b003d7 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -229,6 +229,25 @@ class TimelineItemContentMessageFactoryTest : RobolectricTest() { assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("body")) } + @Test + fun `test create TextMessageType with a formatted body containing only an image falls back to the body`() = runTest { + val sut = createTimelineItemContentMessageFactory() + val result = sut.create( + content = createMessageContent( + type = TextMessageType( + body = "\uD83D\uDE1C", + formatted = FormattedBody(MessageFormat.HTML, "\"\uD83D\uDE1C\"") + ) + ), + senderId = A_USER_ID, + senderProfile = aProfileDetails(), + eventId = AN_EVENT_ID, + ) + assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(SpannedString("\uD83D\uDE1C")) + assertThat(result.htmlDocument).isNull() + assertThat(result.plainText).isEqualTo("\uD83D\uDE1C") + } + @Test fun `test create VideoMessageType`() = runTest { val sut = createTimelineItemContentMessageFactory() diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt index d29a28cba9..2f52320b0d 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt @@ -19,7 +19,8 @@ import org.jsoup.safety.Safelist /** * Converts the HTML string [FormattedBody.body] to a [Document] by parsing it. - * If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`. + * If the message is not formatted, the format is not [MessageFormat.HTML], or nothing renderable is left once the + * unsupported tags have been removed, we return `null` so that the plain text body is used instead. * * This will also make sure mentions are prefixed with `@`. * @@ -44,7 +45,7 @@ fun FormattedBody.toHtmlDocument( // Prepend `@` to mentions fixMentions(dom, permalinkParser) - dom + dom.takeIf { it.text().isNotBlank() } } } diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocumentTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocumentTest.kt index ae336cd0d0..dcf8935e57 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocumentTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocumentTest.kt @@ -103,4 +103,28 @@ class ToHtmlDocumentTest : RobolectricTest() { }) assertThat(document?.text()).isEqualTo("Hey Alice!") } + + @Test + fun `toHtmlDocument - returns null if the body only contains unsupported tags`() { + val body = FormattedBody( + format = MessageFormat.HTML, + body = "\"\uD83D\uDE1C\"" + ) + + val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser()) + + assertThat(document).isNull() + } + + @Test + fun `toHtmlDocument - returns a Document if some text survives next to unsupported tags`() { + val body = FormattedBody( + format = MessageFormat.HTML, + body = "Look at this \"cat\"!" + ) + + val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser()) + + assertThat(document?.text()).isEqualTo("Look at this !") + } } diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt index b1031176e7..aa5cf2e96e 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt @@ -149,4 +149,16 @@ class ToPlainTextTest : RobolectricTest() { ) assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("This is the message content.") } + + @Test + fun `TextMessageType toPlainText - returns the body if the formatted one only contains an image`() { + val messageType = TextMessageType( + body = "\uD83D\uDE1C", + formatted = FormattedBody( + format = MessageFormat.HTML, + body = "\"\uD83D\uDE1C\"" + ) + ) + assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("\uD83D\uDE1C") + } } From 012c5a1f81e2f68c9884b971fead7740896925e5 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 31 Aug 2026 15:45:14 +0200 Subject: [PATCH 10/14] Do some renaming now that user can select multiple answers per poll --- .../messages/impl/timeline/TimelineEvent.kt | 2 +- .../impl/timeline/TimelinePresenter.kt | 4 ++-- .../components/event/TimelineItemPollView.kt | 6 ++--- .../impl/timeline/TimelinePresenterTest.kt | 4 ++-- .../event/TimelineItemPollViewTest.kt | 2 +- .../api/actions/SendPollResponseAction.kt | 6 ++--- .../poll/api/pollcontent/PollContentView.kt | 24 +++++++++---------- .../poll/impl/history/PollHistoryEvent.kt | 2 +- .../poll/impl/history/PollHistoryPresenter.kt | 2 +- .../poll/impl/history/PollHistoryView.kt | 14 +++++------ .../impl/history/PollHistoryPresenterTest.kt | 2 +- .../poll/impl/history/PollHistoryViewTest.kt | 2 +- 12 files changed, 35 insertions(+), 35 deletions(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt index de4031d1a4..66807b74f2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvent.kt @@ -52,7 +52,7 @@ sealed interface TimelineEvent { */ sealed interface TimelineItemPollEvent : TimelineItemEvent - data class SelectPollAnswer( + data class SendPollResponse( val pollStartId: EventId, val answerIds: List ) : TimelineItemPollEvent diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt index 3317d8f05c..2fc4351cea 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt @@ -201,12 +201,12 @@ class TimelinePresenter( newEventState.value = NewEventState.None } } - is TimelineEvent.SelectPollAnswer -> sessionCoroutineScope.launch { + is TimelineEvent.SendPollResponse -> sessionCoroutineScope.launch { timelineController.invokeOnCurrentTimeline { sendPollResponseAction.execute( timeline = this, pollStartId = event.pollStartId, - answerIds = event.answerIds + answerIds = event.answerIds, ).onFailure { error -> Timber.tag(tag).e(error, "Failed to send poll response") } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt index 041f3ad5a5..c51d449fbc 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt @@ -26,8 +26,8 @@ fun TimelineItemPollView( eventSink: (TimelineEvent.TimelineItemPollEvent) -> Unit, modifier: Modifier = Modifier, ) { - fun onSelectAnswer(pollStartId: EventId, answerIds: List) { - eventSink(TimelineEvent.SelectPollAnswer(pollStartId, answerIds)) + fun onSendPollResponse(pollStartId: EventId, answerIds: List) { + eventSink(TimelineEvent.SendPollResponse(pollStartId, answerIds)) } fun onEndPoll(pollStartId: EventId) { @@ -48,7 +48,7 @@ fun TimelineItemPollView( isPollEnded = content.isEnded, isPollEditable = content.isEditable, isMine = content.isMine, - onSelectAnswer = ::onSelectAnswer, + onSendPollResponse = ::onSendPollResponse, onEditPoll = ::onEditPoll, onEndPoll = ::onEndPoll, modifier = modifier, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt index 17cf61459e..290246ffb9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenterTest.kt @@ -951,14 +951,14 @@ class TimelinePresenterTest { } @Test - fun `present - PollAnswerSelected event`() = runTest { + fun `present - SendPollResponse event`() = runTest { val sendPollResponseAction = FakeSendPollResponseAction() val presenter = createTimelinePresenter( sendPollResponseAction = sendPollResponseAction, ) presenter.test { val initialState = awaitFirstItem() - initialState.eventSink.invoke(TimelineEvent.SelectPollAnswer(AN_EVENT_ID, listOf("anAnswerId"))) + initialState.eventSink.invoke(TimelineEvent.SendPollResponse(AN_EVENT_ID, listOf("anAnswerId"))) } delay(1) sendPollResponseAction.verifyExecutionCount(1) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt index b4977e7f3a..2da5691736 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt @@ -50,7 +50,7 @@ class TimelineItemPollViewTest : RobolectricTest() { matcher = hasText(answer.text), useUnmergedTree = true, ).performClick() - eventsRecorder.assertSingle(TimelineEvent.SelectPollAnswer(content.eventId!!, listOf(answer.id))) + eventsRecorder.assertSingle(TimelineEvent.SendPollResponse(content.eventId!!, listOf(answer.id))) } @Test diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt index 6cfc46a754..fe9f5b12a3 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/actions/SendPollResponseAction.kt @@ -12,17 +12,17 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.timeline.Timeline /** - * Sends the current user's answer to a poll, wrapping the timeline call so the analytics are captured in one place. + * Sends the current user's answer(s) to a poll, wrapping the timeline call so the analytics are captured in one place. */ interface SendPollResponseAction { /** * @param timeline the timeline the poll lives in. * @param pollStartId the poll start event being answered. - * @param answerId the id of the chosen answer. + * @param answerIds the ids of the chosen answers. */ suspend fun execute( timeline: Timeline, pollStartId: EventId, - answerIds: List + answerIds: List, ): Result } diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt index f5078ecd50..fdcadd7240 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt @@ -40,7 +40,7 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun PollContentView( state: PollContentState, - onSelectAnswer: (pollStartId: EventId, answerIds: List) -> Unit, + onSendPollResponse: (pollStartId: EventId, answerIds: List) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, @@ -56,7 +56,7 @@ fun PollContentView( isPollEnded = state.isPollEnded, isMine = state.isMine, onEditPoll = onEditPoll, - onSelectAnswer = onSelectAnswer, + onSendPollResponse = onSendPollResponse, onEndPoll = onEndPoll, modifier = modifier, ) @@ -73,7 +73,7 @@ fun PollContentView( isPollEditable: Boolean, isPollEnded: Boolean, isMine: Boolean, - onSelectAnswer: (pollStartId: EventId, answerIds: List) -> Unit, + onSendPollResponse: (pollStartId: EventId, answerIds: List) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, @@ -92,10 +92,10 @@ fun PollContentView( }.filter { it.isSelected }.map { it.answer.id } // Only send the response if the selection count is within the limit if (newSelections.size <= maxSelections.toInt()) { - onSelectAnswer(it, newSelections) + onSendPollResponse(it, newSelections) } } else { - onSelectAnswer(it, listOf(pollAnswer.id)) + onSendPollResponse(it, listOf(pollAnswer.id)) } } } @@ -238,7 +238,7 @@ internal fun PollContentViewUndisclosedPreview() = ElementPreview { isPollEnded = false, isPollEditable = false, isMine = false, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) @@ -257,7 +257,7 @@ internal fun PollContentViewDisclosedPreview() = ElementPreview { isPollEnded = false, isPollEditable = false, isMine = false, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) @@ -276,7 +276,7 @@ internal fun PollContentViewMultipleSelectionPreview() = ElementPreview { isPollEnded = false, isPollEditable = false, isMine = false, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) @@ -295,7 +295,7 @@ internal fun PollContentViewEndedPreview() = ElementPreview { isPollEnded = true, isPollEditable = false, isMine = false, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) @@ -314,7 +314,7 @@ internal fun PollContentViewCreatorEditablePreview() = ElementPreview { isPollEnded = false, isPollEditable = true, isMine = true, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) @@ -333,7 +333,7 @@ internal fun PollContentViewCreatorPreview() = ElementPreview { isPollEnded = false, isPollEditable = false, isMine = true, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) @@ -352,7 +352,7 @@ internal fun PollContentViewCreatorEndedPreview() = ElementPreview { isPollEnded = true, isPollEditable = false, isMine = true, - onSelectAnswer = { _, _ -> }, + onSendPollResponse = { _, _ -> }, onEditPoll = {}, onEndPoll = {}, ) diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvent.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvent.kt index 81cc8f522e..8afd6721ec 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvent.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvent.kt @@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.EventId sealed interface PollHistoryEvent { data object LoadMore : PollHistoryEvent - data class SelectPollAnswer(val pollStartId: EventId, val answerIds: List) : PollHistoryEvent + data class SendPollResponse(val pollStartId: EventId, val answerIds: List) : PollHistoryEvent data class EndPoll(val pollStartId: EventId) : PollHistoryEvent data class SelectFilter(val filter: PollHistoryFilter) : PollHistoryEvent } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt index 46da83e945..44d27ae607 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt @@ -69,7 +69,7 @@ class PollHistoryPresenter( is PollHistoryEvent.LoadMore -> { coroutineScope.loadMore(timeline) } - is PollHistoryEvent.SelectPollAnswer -> sessionCoroutineScope.launch { + is PollHistoryEvent.SendPollResponse -> sessionCoroutineScope.launch { sendPollResponseAction.execute( timeline = timeline, pollStartId = event.pollStartId, diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt index 219c39ed23..40bc2f893a 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt @@ -65,8 +65,8 @@ fun PollHistoryView( state.eventSink(PollHistoryEvent.LoadMore) } - fun onSelectAnswer(pollStartId: EventId, answerIds: List) { - state.eventSink(PollHistoryEvent.SelectPollAnswer(pollStartId, answerIds)) + fun onSendPollResponse(pollStartId: EventId, answerIds: List) { + state.eventSink(PollHistoryEvent.SendPollResponse(pollStartId, answerIds)) } fun onEndPoll(pollStartId: EventId) { @@ -114,7 +114,7 @@ fun PollHistoryView( pollHistoryItems = pollHistoryItems, hasMoreToLoad = state.hasMoreToLoad, isLoading = state.isLoading, - onSelectAnswer = ::onSelectAnswer, + onSendPollResponse = ::onSendPollResponse, onEditPoll = onEditPoll, onEndPoll = ::onEndPoll, onLoadMore = ::onLoadMore, @@ -151,7 +151,7 @@ private fun PollHistoryList( pollHistoryItems: ImmutableList, hasMoreToLoad: Boolean, isLoading: Boolean, - onSelectAnswer: (pollStartId: EventId, answerIds: List) -> Unit, + onSendPollResponse: (pollStartId: EventId, answerIds: List) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit, onLoadMore: () -> Unit, @@ -166,7 +166,7 @@ private fun PollHistoryList( items(pollHistoryItems) { pollHistoryItem -> PollHistoryItemRow( pollHistoryItem = pollHistoryItem, - onSelectAnswer = onSelectAnswer, + onSendPollResponse = onSendPollResponse, onEditPoll = onEditPoll, onEndPoll = onEndPoll, modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) @@ -222,7 +222,7 @@ private fun LoadMoreButton(isLoading: Boolean, onClick: () -> Unit) { @Composable private fun PollHistoryItemRow( pollHistoryItem: PollHistoryItem, - onSelectAnswer: (pollStartId: EventId, answerIds: List) -> Unit, + onSendPollResponse: (pollStartId: EventId, answerIds: List) -> Unit, onEditPoll: (pollStartId: EventId) -> Unit, onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, @@ -244,7 +244,7 @@ private fun PollHistoryItemRow( Spacer(modifier = Modifier.height(4.dp)) PollContentView( state = pollHistoryItem.state, - onSelectAnswer = onSelectAnswer, + onSendPollResponse = onSendPollResponse, onEditPoll = onEditPoll, onEndPoll = onEndPoll, ) diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt index d1c67943c8..ca20cc2ee9 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt @@ -117,7 +117,7 @@ class PollHistoryPresenterTest { state.eventSink(PollHistoryEvent.EndPoll(AN_EVENT_ID)) runCurrent() endPollAction.verifyExecutionCount(1) - state.eventSink(PollHistoryEvent.SelectPollAnswer(AN_EVENT_ID, listOf("answer"))) + state.eventSink(PollHistoryEvent.SendPollResponse(AN_EVENT_ID, listOf("answer"))) runCurrent() sendPollResponseAction.verifyExecutionCount(1) cancelAndConsumeRemainingEvents() diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt index 998ec0ca48..e0a6eb98b1 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt @@ -133,7 +133,7 @@ class PollHistoryViewTest : RobolectricTest() { useUnmergedTree = true, ).performClick() eventsRecorder.assertSingle( - PollHistoryEvent.SelectPollAnswer(eventId, listOf(answer.id)) + PollHistoryEvent.SendPollResponse(eventId, listOf(answer.id)) ) } From 987d343379a6e6cf9311163ebfeb627b4f690af5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:05:00 +0000 Subject: [PATCH 11/14] Update roborazzi to v1.73.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5be4eab26f..70bf6894bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,7 +32,7 @@ accompanist = "0.37.3" # Test test_core = "1.7.0" -roborazzi = "1.72.0" +roborazzi = "1.73.0" # Jetbrain datetime = "0.8.0" From e5065d9baac2873a4063e2327c74ae5c380d6703 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 1 Sep 2026 12:40:05 +0200 Subject: [PATCH 12/14] Fix invalid placeholders in translations (#7612) * Add logic to find invalid string placeholders in `checkForbiddenTerms.py` * Fix found invalid placeholder in Chinese translations --- .../src/main/res/values-zh/translations.xml | 2 +- tools/localazy/checkForbiddenTerms.py | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/features/poll/api/src/main/res/values-zh/translations.xml b/features/poll/api/src/main/res/values-zh/translations.xml index 15f71240d4..4cde80b945 100644 --- a/features/poll/api/src/main/res/values-zh/translations.xml +++ b/features/poll/api/src/main/res/values-zh/translations.xml @@ -1,7 +1,7 @@ - "占总投票的 %1$d%" + "占总投票的 %1$d%%" "将移除之前的选择" "此为胜出的答案" diff --git a/tools/localazy/checkForbiddenTerms.py b/tools/localazy/checkForbiddenTerms.py index 48bf7030b8..3bf893dd0b 100755 --- a/tools/localazy/checkForbiddenTerms.py +++ b/tools/localazy/checkForbiddenTerms.py @@ -42,6 +42,31 @@ forbiddenTerms = { ] } +# A complete, VALID placeholder, anchored to consume the whole token: +# %% -> literal percent (allowed) +# %s %d %f ... -> simple conversion +# %1$s %2$d ... -> positional +# %.2f %1$.2f -> optional precision +_VALID_PLACEHOLDER = re.compile( + r'%(?:\d+\$)?(?:\.\d+)?[a-zA-Z]' +) + +# Grab each candidate token: a '%' plus the run of chars that could belong to a +# specifier (digits, '$', '.', and letters). This makes '%sd' and '%1d' surface +# as single tokens instead of being partially swallowed. +_PLACEHOLDER_MATCHES = re.compile(r'%%|%[0-9$.A-Za-z]*') + +def find_invalid_placeholders(value): + """Return the list of malformed placeholder tokens found in `value`.""" + invalid = [] + for token in _PLACEHOLDER_MATCHES.findall(value): + if token == '%%': + continue # escaped percent is fine + if not _VALID_PLACEHOLDER.fullmatch(token): + invalid.append(token) + return invalid + + content = minidom.parse(file) errors = [] @@ -60,6 +85,9 @@ for elem in content.getElementsByTagName('string'): matches = re.search(term, value) if matches and name not in exceptions: errors.append('Forbidden term "' + term + '" in string: "' + name + '": ' + value) + invalid_placeholders = find_invalid_placeholders(value) + for placeholder in invalid_placeholders: + errors.append('Invalid placeholder "' + placeholder + '" in string: "' + name + '": ' + value) ### Plurals for elem in content.getElementsByTagName('plurals'): @@ -78,6 +106,9 @@ for elem in content.getElementsByTagName('plurals'): matches = re.search(term, value) if matches and name not in exceptions: errors.append('Forbidden term "' + term + '" in plural: "' + name + '": ' + value) + invalid_placeholders = find_invalid_placeholders(value) + for placeholder in invalid_placeholders: + errors.append('Invalid placeholder "' + placeholder + '" in plural: "' + name + '": ' + value) # If errors is not empty print the report if errors: From 7c42f22b7c105bfd849f96e77ecab7b9a818d905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 1 Sep 2026 13:57:32 +0200 Subject: [PATCH 13/14] Setting version for the release 26.09.1 --- plugins/src/main/kotlin/Versions.kt | 4 ++-- tools/manifest/gplay/release/aaptDump.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 0c2885aaa1..e33351f2ef 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -39,13 +39,13 @@ private const val versionYear = 26 * Month of the version on 2 digits. Value must be in [1,12]. * Do not update this value. it is updated by the release script. */ -private const val versionMonth = 8 +private const val versionMonth = 9 /** * Release number in the month. Value must be in [0,99]. * Do not update this value. it is updated by the release script. */ -private const val versionReleaseNumber = 4 +private const val versionReleaseNumber = 1 object Versions { /** diff --git a/tools/manifest/gplay/release/aaptDump.txt b/tools/manifest/gplay/release/aaptDump.txt index ff8cec3036..b67c1f527c 100644 --- a/tools/manifest/gplay/release/aaptDump.txt +++ b/tools/manifest/gplay/release/aaptDump.txt @@ -1,4 +1,4 @@ -package: name='io.element.android.x' versionCode='202608040' versionName='26.08.4' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' +package: name='io.element.android.x' versionCode='202609010' versionName='26.09.1' platformBuildVersionName='17' platformBuildVersionCode='37' compileSdkVersion='37' compileSdkVersionCodename='17' sdkVersion:'24' targetSdkVersion:'37' uses-permission: name='android.permission.POST_NOTIFICATIONS' From aa695d2ad6af94f278464cc0e4ccdb9702a8549c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 1 Sep 2026 13:57:54 +0200 Subject: [PATCH 14/14] Adding fastlane file for version 26.09.1 --- fastlane/metadata/android/en-US/changelogs/202609010.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/202609010.txt diff --git a/fastlane/metadata/android/en-US/changelogs/202609010.txt b/fastlane/metadata/android/en-US/changelogs/202609010.txt new file mode 100644 index 0000000000..e3380c06f5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202609010.txt @@ -0,0 +1,2 @@ +Main changes in this version: important bug fixes and improvements. +Full changelog: https://github.com/element-hq/element-x-android/releases