Skip to content

Show the single answer poll subtitle when enforceUniqueVote is set - #6704

Draft
ryanhurststrava wants to merge 1 commit into
GetStream:developfrom
ryanhurststrava:fix/poll-subtitle-enforce-unique-vote
Draft

ryanhurststrava wants to merge 1 commit into
GetStream:developfrom
ryanhurststrava:fix/poll-subtitle-enforce-unique-vote

Conversation

@ryanhurststrava

@ryanhurststrava ryanhurststrava commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Goal

Poll.getSubtitle() shows "Select one or more" for polls that only allow a single answer, when those polls were created by the iOS SDK.

getSubtitle() decides the subtitle from maxVotesAllowed alone:

val maxVotes = maxVotesAllowed?.let { min(it, options.size) }
return when (maxVotes) {
    1 -> ...stream_ui_poll_description_single_answer
    null -> ...stream_ui_poll_description_unlimited_answers
    else -> ...stream_ui_poll_description_multiple_answers
}

Poll also carries enforceUniqueVote: Boolean, which is the authoritative "one answer only" signal, and it is ignored here.

Why this only shows up for iOS-created polls. When the Android SDK creates a single-answer poll it sets both fields (AttachmentsPickerPollUtils.kt: maxVotesAllowed = 1, enforceUniqueVote = true), so the 1 -> branch is hit and the subtitle is correct. The iOS SDK sends enforce_unique_vote: true and omits max_votes_allowed (the nil value is skipped by Swift's synthesized Encodable). The Android client therefore deserializes maxVotesAllowed == null, falls into the null -> branch, and renders the unlimited-answers string for a poll that permits exactly one vote.

When this became reachable. #6209 moved maxVotesAllowed from Int to Int? so that null could mean "unlimited votes". That is a deliberate and correct meaning for polls created without a vote limit — this PR does not undo it. The problem is that a payload omitting max_votes_allowed is currently treated as "unlimited" even when enforce_unique_vote: true is present, and enforceUniqueVote is exactly the field that disambiguates the two cases.

The broken branch ordering is unchanged as of the latest release, v7.11.0, and unchanged on current develop (this PR is based on 929fd8c51f4).

Cross-platform inconsistency. StreamChatSwiftUI's PollAttachmentView already checks enforceUniqueVote before maxVotesAllowed:

} else if poll.enforceUniqueVote == true {
    L10n.Message.Polls.Subtitle.selectOne
} else if let maxVotes = poll.maxVotesAllowed, maxVotes > 0 {

So the same poll reads "Select one" on iOS and "Select one or more" on Android.

Both Android render paths are affected, since they share this helper:

  • stream-chat-android-compose/.../ui/components/messages/PollMessageContent.kt
  • stream-chat-android-ui-components/.../messages/list/adapter/view/internal/PollView.kt

Tracked in AND-1539, filed by the Stream team after this PR was opened.

Implementation

One added branch in stream-chat-android-ui-common/.../utils/extensions/Poll.kt — check enforceUniqueVote before the maxVotesAllowed branches, so it wins regardless of whether maxVotesAllowed is present:

if (closed) {
    return context.getString(R.string.stream_ui_poll_description_closed)
}
if (enforceUniqueVote) {
    return context.getString(R.string.stream_ui_poll_description_single_answer)
}
val maxVotes = maxVotesAllowed?.let { min(it, options.size) }

The closed short-circuit and the min(maxVotesAllowed, options.size) clamp are unchanged. No public API signature changes (apiCheck passes with no dump needed).

A question about canCastVote(), two functions above

public fun Poll.canCastVote(): Boolean {
    val limit = maxVotesAllowed ?: return true
    return ownVotes.size < limit
}

This has the same blind spot: for an enforceUniqueVote poll with a null maxVotesAllowed, it returns true unconditionally, and it gates vote casting in PollOptionVotingRow, PollView, and AllPollOptionsDialogFragment. I deliberately did not touch it, because I couldn't determine the intended behaviour with confidence and the naive fix looks like it would be wrong: on a single-answer poll a user should presumably still be able to switch their vote to another option, and returning false once they hold one vote would block that. Uniqueness may also be enforced server-side (replacing the existing vote) rather than client-side. Is that the case, and is the current behaviour here intentional? Happy to follow up in a separate PR if you'd like it changed.

🎨 UI Changes

Text-only change on the poll subtitle line, for enforceUniqueVote polls with no maxVotesAllowed:

Before After
"Select one or more" "Select one"

Existing Paparazzi snapshots are unaffected — PreviewPollData.poll1 sets maxVotesAllowed = 1 alongside enforceUniqueVote = true, so its subtitle is unchanged. :stream-chat-android-compose:verifyPaparazziDebug --tests "*PollMessageContentTest*" passes without re-recording.

Testing

To reproduce: create a poll from the iOS SDK with "Multiple answers" left off (and no per-person vote limit set), then view that poll in either Android UI kit. Before this change the subtitle reads "Select one or more"; after, "Select one".

PollExtensionsTest covers the branch table. Added:

  • enforceUniqueVote = true, maxVotesAllowed = null → single-answer string (the bug)
  • enforceUniqueVote = true, maxVotesAllowed = 1 → single-answer string (unchanged)
  • enforceUniqueVote = false, maxVotesAllowed = null → unlimited-answers string (regression guard: genuinely unlimited polls must not be caught by the new branch)

The pre-existing getSubtitle cases also had to pin enforceUniqueVote = false explicitly. They relied on randomPoll, whose enforceUniqueVote default is randomBoolean(), so once the new branch exists they would flake roughly half the time.

./gradlew :stream-chat-android-ui-common:testDebugUnitTest --tests "*PollExtensionsTest*"   # 10 tests, 0 failures
./gradlew :stream-chat-android-ui-common:spotlessCheck :stream-chat-android-ui-common:detekt :stream-chat-android-ui-common:apiCheck   # BUILD SUCCESSFUL
./gradlew :stream-chat-android-compose:verifyPaparazziDebug --tests "*PollMessageContentTest*"   # BUILD SUCCESSFUL

I ran module-scoped tasks only, not the full ./gradlew check or the instrumented/E2E suites.

☑️Contributor Checklist

General

  • I have signed the Stream CLA (required) — not yet signed; will do so before merge
  • Assigned a person / code owner group (required)
  • PR targets the develop branch
  • PR is linked to the GitHub issue it resolves (AND-1539)

Code & documentation

  • New code is covered by unit tests
  • Comparison screenshots added for visual changes — text-only, described in the table above
  • Affected documentation updated (KDocs, docusaurus, tutorial) — the existing KDoc still describes the behaviour accurately

Summary by CodeRabbit

  • Bug Fixes
    • Poll subtitles now accurately describe single-answer voting when unique voting is enforced.
    • Poll subtitles continue to reflect multiple-answer and unlimited-answer settings correctly.

getSubtitle() branched on maxVotesAllowed alone, so a single-answer poll
that carries enforceUniqueVote = true without an explicit maxVotesAllowed
fell into the null branch and rendered "Select one or more".

Check enforceUniqueVote before the maxVotesAllowed branches, matching the
ordering the Swift SDK's PollAttachmentView already uses.
@gpunto gpunto added the pr:bug Bug fix label Sep 16, 2026
@andremion

Copy link
Copy Markdown
Contributor

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: f293374e-89bb-4f97-91da-a51db141f9a2

📥 Commits

Reviewing files that changed from the base of the PR and between 929fd8c and 77d4ba2.

📒 Files selected for processing (2)
  • stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Poll.kt
  • stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/PollExtensionsTest.kt

Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review.


Walkthrough

Poll.getSubtitle now returns the single-answer description when enforceUniqueVote is enabled. Tests cover unique voting, vote limits, multiple answers, and unlimited answers.

Changes

Poll subtitle behavior

Layer / File(s) Summary
Subtitle selection and validation
stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Poll.kt, stream-chat-android-ui-common/src/test/kotlin/io/getstream/chat/android/ui/common/utils/extensions/PollExtensionsTest.kt
Poll.getSubtitle handles enforceUniqueVote before vote-limit checks. Tests cover unique voting, nullable and explicit limits, multiple answers, and unlimited answers.

Priority: ⬇️ Low

Estimated code review effort: 1 (Trivial) | ~5 minutes

Change: Bug fix

Suggested reviewers: gpunto

Merge Risk: ⚪ Minimal · up to 77d4b

Unique-vote polls now show the single-answer subtitle without changing closed-poll or non-unique behavior. The change is ready to merge.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: showing the single-answer poll subtitle when enforceUniqueVote is enabled.
Description check ✅ Passed The description is comprehensive and covers the goal, implementation, UI impact, testing, and most contributor checklist items. It also documents the intentional exclusion of canCastVote(). The Review…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit checks each voting choice
Unique votes now speak with one clear voice
Limits and nulls line up just right
Tests hop through each case in sight
The poll subtitle shines bright

Comment @coderabbitai help to get the list of available commands.

@andremion andremion changed the title Respect enforceUniqueVote in Poll.getSubtitle Show the single answer poll subtitle when enforceUniqueVote is set Sep 16, 2026

@andremion andremion left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. The fix is right, and I confirmed on the iOS side that a single answer poll sends enforce_unique_vote: true and omits max_votes_allowed, so the null branch is exactly the problem you describe.

Two things that don't fit on a line:

  1. The same check exists once more, in PollOptionVotingRow.kt:109 in the Compose kit, outside this diff:
val toggleRole = if (poll.maxVotesAllowed == 1) Role.RadioButton else Role.Checkbox

For the same poll the subtitle will now read "Select one" while TalkBack still announces the options as checkboxes. Would you like to fix it here? It is the same one line shape. Also fine to leave it for a follow-up if you'd rather keep this PR small.

  1. Housekeeping done on our side: I filed AND-1539, linked it in the description, and adjusted the title. The pr-checklist checks themselves pass now, the job only stays red because it cannot post its own comment from a fork, so you can ignore it. The CLA is the one part we can't handle for you.

if (closed) {
return context.getString(R.string.stream_ui_poll_description_closed)
}
if (enforceUniqueVote) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On your canCastVote() question: you were right to leave it alone. With enforce_unique_vote the server treats a vote on a different option as a vote change, so returning true there is the correct behaviour.

The reverse case is the broken one. Our own single answer polls carry both maxVotesAllowed = 1 and enforceUniqueVote = true, so canCastVote() returns false after the first vote, and PollOptionVotingRow.kt:111 and PollView.kt:308 then swallow a tap on another option. The user has to deselect first. iOS lets the switch through.

On your offer to handle that separately: yes please, a follow-up PR would be welcome. Nothing needed in this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants