Skip to content

refactor(llc)!: route polls through the generated client - #3037

Open
VelikovPetar wants to merge 27 commits into
v11from
refactor/flu-882_polls_openapi_migration
Open

VelikovPetar wants to merge 27 commits into
v11from
refactor/flu-882_polls_openapi_migration

Conversation

@VelikovPetar

@VelikovPetar VelikovPetar commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Submit a pull request

Linear: FLU-882
Github Issue: -

CLA

  • I have signed the Stream CLA (required).
  • The code changes follow best practices
  • Code changes are tested (add some information if not applicable)

Description of the pull request

Routes the poll endpoints through the generated OpenAPI client and turns Poll, PollOption and PollVote into domain models. Every poll call on StreamChatClient and Channel now returns a Result.

  • Poll, PollOption and PollVote are @freezed with no JSON; VotingVisibility is an extension type.
  • PollsRepository and polls_mapper.dart map the generated types to the public models.
  • Message.poll, Event.poll and Event.pollVote keep decoding the v1 / WebSocket JSON through dedicated v1 converters. DraftMessage.poll carries the same converter only so the generated code compiles: a draft payload has poll_id only, and the field is set by constructors alone (left for group 07).
  • Channel.sendPoll returns Result<SendMessageResponse>.
  • Channel.castPollVote / removePollVote with an option or vote that has no id return Failure(StreamClientException) instead of throwing an ArgumentError.
  • Queries page with limit (default 10), next and prev; prev is exposed on the responses.
  • Persistence stores PollOption through fromData / toData and keeps an unknown VotingVisibility (schema bump).
  • StreamPollVoteListController folds the Result.
  • Removed: PollsApi, StreamChatApi.polls and the v1 poll DTOs.
Generated Public
PollResponseData Poll
PollOptionResponseData PollOption
PollVoteResponseData PollVote
PollResponse PollResponse
PollOptionResponse PollOptionResponse
PollVoteResponse PollVoteResponse
QueryPollsResponse QueryPollsResponse
PollVotesResponse QueryPollVotesResponse
PollsApi PollsRepository

Migration plan and skill

  • copyWith rule: Poll, PollOption and PollVote use freezed's generated copyWith. It differs from v10's hand-written one: null clears a nullable field where v10 kept it, and null for Poll.id, createdAt or updatedAt re-applies the constructor default. The openapi-migration skill gains copywith-audit.md and scripts/copywith_audit.py, which probe a model with freezed's copyWith and list every call site whose behaviour or compilation changes; README rule 2 and the definition of done now ask for that audit before a v10 copyWith is deleted, and say when freezed's cannot replace it (a parameter backed by a getter, a private field or a different type, as on Message). On the poll models the audit found one lib site (stream_message_preview_text.dart) and one test per model; the event merging in channel_state_mutations.dart and Poll.withTranslationsOf never pass null while the field is set, so they are unchanged.
  • Group 05: its decisions are recorded in generate_plan.py, and 05-polls.md is regenerated from it.
  • Custom data rule: README rule 9 says a migrated model names its custom data custom, the wire's custom, verbatim, in both directions, and that a typed wire field is never parked in extraData behind a getter. The polls mapper follows it: Poll.extraData / PollOption.extraData are the generated custom map as is, and the requests send extraData as custom; the v1 writers leave a custom entry named like a key out so it cannot overwrite the key. Groups 09 and 11 get the directive to promote User.name/image and the ChannelModel flags (hidden, blocked, …) to fields, and group 15 becomes the custom-data pass: no mapper filtering, nothing parked, and the rename of whatever no group migrates.
  • Group 09: the REST/v1 User.extraData difference is added as a risk and a definition-of-done item.

Why

Part of moving v11 onto the generated client. Backend behaviour this relies on:

  • Poll queries page with limit, next and prev only; the v10 offset, id_* and created_at_* parameters were always ignored.
  • Option ids on create and vote ids on cast were never honoured.

⚠️ Please review closely

  • The 8 v10 poll envelopes collapse to 3.
  • Poll and PollOption equality now includes extraData, so a Message carrying such a poll compares differently.
  • Poll.extraData and PollOption.extraData are renamed custom (a hard rename, no alias; StreamPollController.addOption / updateOption follow), the first models to take the wire's name. They are the wire's custom map verbatim, and the requests send them back as custom data; a custom entry named like one of the poll's own fields (name, an option's text) is custom data on v2, where v10's flattened body let it overwrite the field. A poll from a REST call and the same poll decoded from a v1 event can differ in extraData when such an entry exists, since v1's flat JSON cannot carry both. Poll.topLevelFields and PollOption.topLevelFields are removed; the v1 converter owns the key lists.
  • Poll.copyWith, PollOption.copyWith and PollVote.copyWith are freezed's: passing null clears a nullable field (v10 kept it, except Poll.maxVotesAllowed and PollOption.id, which already cleared) and re-applies the id and timestamp defaults.
  • updatePoll / updatePollOption always send the request: an option without an id goes out with an empty one (the generated request types require a String), and the API rejects it, as it did in v10. The SDK doesn't pre-check a rule the backend owns.
  • Write inputs stay Poll / PollOption. createPoll, updatePoll, createPollOption and updatePollOption still take the full model, and the mapper sends only the fields the request declares. Server-owned fields such as voteCount, latestVotesByOption, ownVotesAndAnswers, createdBy and the timestamps are not sent. The alternative was dedicated request models under models/request/ (for example a CreatePollRequest holding only the writable fields), which would make the contract explicit in the type. We didn't go that way because it adds new public types and changes every call site, including StreamPollController, which builds a Poll. v10 didn't send these fields either, since toJson excluded them.
  • Pagination is limit / next / prev (limit still 10), with prev on the responses.
  • Poll.latestVotes, ownVotes and ownAnswers stay cached late final lists, as in v10, rather than getters that rebuild the list on every read during build. The cached list is mutable and shared, as it was before. freezed counts them in ==, hashCode and toString; they are derived from latestVotesByOption and ownVotesAndAnswers, which are compared already, so equality is unchanged.
  • Poll translations come through as the typed fields feat(llc, persistence, ui): show auto-translated polls #3003 added (Poll.nameI18n / descriptionI18n, PollOption.textI18n, PollVote.answerTextI18n), mapped from the generated responses and from v1 JSON alike, and stay out of extraData. They are never sent back to the API; the offline cache keeps them.
  • createPoll / sendPoll with a client-set option id: the generated PollOptionInput has no id, so it is not sent and the server assigns one. In v10 it was sent and the server rejected it (400 options[0].id is a reserved field). The SDK's poll creator already cleared these ids, so only direct calls see the difference.
  • The UI calls the poll methods without awaiting them (poll_attachment.dart, stream_poll_options_sheet.dart, stream_poll_comments_sheet.dart). Nothing changes on screen, but a failed call used to escape as an uncaught async error that Crashlytics / Sentry recorded; it is now a Failure nobody reads. Surfacing these in the UI is left for a follow-up.

Testing

  • stream_chat 1,943, stream_chat_persistence 314 and stream_chat_flutter_core 384 tests pass, with new client, channel, v1-decoding, model and controller tests. The poll client tests live in client_polls_test.dart.
  • A before/after harness against the live API covering all 13 endpoints, the error cases, the WebSocket events and Message.poll decoding gave identical results, except:
    1. Creating a poll with a client option id was a 400 and now succeeds (see above).
    2. On REST poll responses, createdBy and a vote's user no longer carry blocked_user_ids, deleted_at, deactivated_at or revoke_tokens_issued_before in extraData. The generated UserResponse types them and User doesn't model them yet. Users decoded from v1 / WebSocket JSON still carry them, so a poll from a REST call and the same poll from an event can compare unequal. In practice only sendPoll's local message holds a REST poll, until the server's reply replaces it. Tracked for the User restructure in group 09 of the plan.
  • Re-run after the custom changes (verbatim custom data, extraData → custom, polls.custom column): the same live harness against the demo app is identical to the previous pass on all 36 scenarios (the only diffs are pagination cursors and the per-run order of ownVotesAndAnswers). A probe confirmed the backend stores custom keys named like a poll's or option's own fields (name, is_closed, an option's text and id) as custom data and returns them unchanged through create, get, update and update-option. On the Android emulator the sample app rebuilt its cache at schema 1104 (polls.custom column, options stored with a custom key) and rendered both polls of the test channel from the cache after a relaunch with Wi-Fi and data off.
  • Sample app before (web) and after (Android and web): create, vote, max-votes limit, unvote, comment, suggest an option, results, paged comments, live votes and comments from another user, end vote, anonymous poll. An offline relaunch renders polls from the cache, and a vote made offline fails quietly.

Screenshots / Videos

N/A: no UI change.

🤖 Generated with Claude Code

VelikovPetar and others added 6 commits September 29, 2026 17:31
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	packages/stream_chat/lib/src/client/client.dart
@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: dc587089-7a3c-460a-8ebd-cd20d4546c8a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

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

VelikovPetar and others added 10 commits October 1, 2026 16:34
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ient_test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mappers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-openapi-migration

# Conflicts:
#	migrations/v11-migration.md
#	packages/stream_chat/CHANGELOG.md
…igration' into claude/polls-openapi-migration-0dc838

# Conflicts:
#	migrations/v11-migration.md
#	openapi-migration/12-uploads-cdn.md
#	packages/stream_chat/CHANGELOG.md
#	packages/stream_chat/lib/src/core/api/responses.g.dart
…esponse

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…igration' into claude/polls-openapi-migration-0dc838

# Conflicts:
#	packages/stream_chat/CHANGELOG.md
Base automatically changed from refactor/flu-846_guest-openapi-migration to v11 October 3, 2026 07:40
VelikovPetar and others added 2 commits October 3, 2026 09:41
…igration-0dc838

# Conflicts:
#	migrations/v11-migration.md
#	openapi-migration/04-roles-guest-and-app.md
#	openapi-migration/09-users.md
#	openapi-migration/README.md
#	openapi-migration/tool/generate_plan.py
#	packages/stream_chat/CHANGELOG.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@VelikovPetar
VelikovPetar requested a review from a team October 5, 2026 08:37
@VelikovPetar
VelikovPetar marked this pull request as ready for review October 5, 2026 08:37
# Conflicts:
#	packages/stream_chat/lib/src/ws/events/event.g.dart
@codecov

codecov Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.68454% with 1 line in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (v11@e4533d9). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...core/lib/src/stream_poll_vote_list_controller.dart 95.83% 1 Missing ⚠️
Additional details and impacted files
@@          Coverage Diff           @@
##             v11    #3037   +/-   ##
======================================
  Coverage       ?   78.48%           
======================================
  Files          ?      478           
  Lines          ?    29284           
  Branches       ?        0           
======================================
  Hits           ?    22984           
  Misses         ?     6300           
  Partials       ?        0           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

VelikovPetar and others added 5 commits October 9, 2026 14:26
# Conflicts:
#	packages/stream_chat_flutter_core/CHANGELOG.md
…oll requests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
VelikovPetar and others added 2 commits October 13, 2026 16:00
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

⚠️ Database Entity Files Modified

The following database entity files have been modified in this PR:

packages/stream_chat_persistence/lib/src/entity/polls.dart

📝 Remember to:

  1. ✅ Database schema version bumped to 1104.
  2. Update entity schema tests if necessary.

Note: This comment is automatically generated by the CI workflow.

///
/// A visibility without a constant here can still be named by wrapping its
/// value: `VotingVisibility('public')`.
extension type const VotingVisibility(String rawType) implements String {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can make the rawType private as we already implement String

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe also check for similar enums in the SDK

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done for VotingVisibility: it is now VotingVisibility(String _), the poll filter field, the v1 converter and the persistence converter use the value as a String, and the migration guide says to use it directly. I'll make the same change on the other extension types in a separate PR.

@xsahil03x xsahil03x left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

First pass, limited to STYLE_GUIDE.md, EFFECTIVE_DART_DOC.md and TESTING.md; a code and behaviour review follows separately.

Mostly wording: a few docs that should be noun phrases, cursor references that can link, and two ambiguous words. Also two changelog entries the changelog policy would trim, and test layout: the poll client tests in client_test.dart, a test checking four things at once, and src/ imports.

analyze is clean, dart format changes nothing, and the stream_chat, stream_chat_flutter_core and stream_chat_persistence suites pass at d4453451. Each suggestion was applied locally, re-analyzed and its tests re-run.

Comment on lines +1 to +4
/// Who can see which option each user voted for in a poll.
///
/// A visibility without a constant here can still be named by wrapping its
/// value: `VotingVisibility('public')`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Effective Dart, PREFER starting library or type comments with noun phrases: the summary should describe an instance of the type, and "Who can see…" is a clause. The example also wraps 'public', which already has a constant, so it doesn't show what the sentence is about.

Suggested change
/// Who can see which option each user voted for in a poll.
///
/// A visibility without a constant here can still be named by wrapping its
/// value: `VotingVisibility('public')`.
/// The rule for who can see which option each user voted for in a poll.
///
/// A visibility without a constant here can still be named by wrapping its
/// value: `VotingVisibility('members_only')`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied.

Comment on lines +6 to +10
/// Votes are counted, but who cast them is hidden from other users.
static const anonymous = VotingVisibility('anonymous');

/// Everyone can see who voted for which option.
static const public = VotingVisibility('public');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Effective Dart, PREFER starting a non-boolean variable or property comment with a noun phrase: both constants are documented with full sentences rather than describing the value.

Suggested change
/// Votes are counted, but who cast them is hidden from other users.
static const anonymous = VotingVisibility('anonymous');
/// Everyone can see who voted for which option.
static const public = VotingVisibility('public');
/// A visibility where votes are counted, but who cast them is hidden from
/// other users.
static const anonymous = VotingVisibility('anonymous');
/// A visibility where everyone can see who voted for which option.
static const public = VotingVisibility('public');

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied.

final List<PollOption> options;

/// Represents the visibility of the voting process.
/// Who can see which option each user voted for.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Effective Dart, PREFER starting a non-boolean variable or property comment with a noun phrase: "Who can see…" is a clause.

Suggested change
/// Who can see which option each user voted for.
/// The rule for who can see which option each user voted for.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied.

final bool allowUserSuggestedOptions;

/// If true, users can provide their own answers/comments.
/// Whether users may leave a free-form answer.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Effective Dart, PREFER brevity ("be clear and precise"): "leave an answer" / "answers left on this poll" (here and on answersCount and latestAnswers) can read as "answers remaining". "Add" is unambiguous.

Suggested change
/// Whether users may leave a free-form answer.
/// Whether users may add a free-form answer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied.


/// The total number of answers received by the poll.
@JsonKey(includeToJson: false)
/// The total number of answers left on this poll.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same as on allowAnswers: "left" can read as "remaining".

Suggested change
/// The total number of answers left on this poll.
/// The total number of answers added to this poll.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied.

expect(res.getOrNull()?.app.fileUploadConfig.sizeLimit, UploadConfig.defaultSizeLimit);
});

test('StreamChatClient.createPoll sends the poll settings and returns the created poll', () async {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

STYLE_GUIDE, Prefer more test files, avoid long test files and Make each test entirely self-contained: about 40 poll tests are added to this 7,200-line file, inside 'Client with connected user without persistence'. That group's setUp connects a fake WebSocket and waits delay(300) before every test, though these tests only touch defaultApi, so it adds roughly 12 s of waiting. poll_fixtures.dart is only used here, which suggests they were already meant to stand apart.

Could they move to test/src/client/client_polls_test.dart, next to channel/channel_polls_test.dart, with a local helper that builds the client on a MockDefaultApi without connectUser?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved to test/src/client/client_polls_test.dart, with a client built on a MockDefaultApi and no connectUser, like moderation_client_test.dart. The poll fallbacks moved with them, and the stream_chat suite runs about 13 s faster.

Comment on lines +151 to +184
test('Event.toJson writes the poll settings with the custom data beside them, without the translations or a custom '
'entry named like a key', () {
final event = Event(
type: EventType.pollUpdated,
poll: Poll(
id: 'poll-id',
name: 'Lunch?',
nameI18n: const {'it': 'Pranzo?'},
description: 'Pick one',
descriptionI18n: const {'it': 'Scegline uno'},
options: const [
PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red', 'id': 'pasta'}),
],
voteCount: 3,
custom: const {'topic': 'food', 'name': 'Dinner?'},
),
);

expect(event.toJson()['poll'], {
'topic': 'food',
'id': 'poll-id',
'name': 'Lunch?',
'description': 'Pick one',
'options': [
{'color': 'red', 'id': 'pizza', 'text': 'Pizza'},
],
'voting_visibility': 'public',
'enforce_unique_vote': true,
'max_votes_allowed': null,
'allow_user_suggested_options': false,
'allow_answers': false,
'is_closed': false,
});
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

TESTING.md, One behavior per test: this test checks four things: the settings are written, custom data sits at the root, translations are dropped, and a custom entry named like a key is dropped (for both the poll and the option). If the collision handling regresses, the failure won't say which part broke.

Suggested change
test('Event.toJson writes the poll settings with the custom data beside them, without the translations or a custom '
'entry named like a key', () {
final event = Event(
type: EventType.pollUpdated,
poll: Poll(
id: 'poll-id',
name: 'Lunch?',
nameI18n: const {'it': 'Pranzo?'},
description: 'Pick one',
descriptionI18n: const {'it': 'Scegline uno'},
options: const [
PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red', 'id': 'pasta'}),
],
voteCount: 3,
custom: const {'topic': 'food', 'name': 'Dinner?'},
),
);
expect(event.toJson()['poll'], {
'topic': 'food',
'id': 'poll-id',
'name': 'Lunch?',
'description': 'Pick one',
'options': [
{'color': 'red', 'id': 'pizza', 'text': 'Pizza'},
],
'voting_visibility': 'public',
'enforce_unique_vote': true,
'max_votes_allowed': null,
'allow_user_suggested_options': false,
'allow_answers': false,
'is_closed': false,
});
});
test('Event.toJson writes the poll settings with the custom data beside them, without the translations', () {
final event = Event(
type: EventType.pollUpdated,
poll: Poll(
id: 'poll-id',
name: 'Lunch?',
nameI18n: const {'it': 'Pranzo?'},
description: 'Pick one',
descriptionI18n: const {'it': 'Scegline uno'},
options: const [
PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red'}),
],
voteCount: 3,
custom: const {'topic': 'food'},
),
);
expect(event.toJson()['poll'], {
'topic': 'food',
'id': 'poll-id',
'name': 'Lunch?',
'description': 'Pick one',
'options': [
{'color': 'red', 'id': 'pizza', 'text': 'Pizza'},
],
'voting_visibility': 'public',
'enforce_unique_vote': true,
'max_votes_allowed': null,
'allow_user_suggested_options': false,
'allow_answers': false,
'is_closed': false,
});
});
test('Event.toJson leaves out a poll custom entry named like a poll key', () {
final event = Event(
type: EventType.pollUpdated,
poll: Poll(id: 'poll-id', name: 'Lunch?', options: const [], custom: const {'name': 'Dinner?'}),
);
final poll = event.toJson()['poll'] as Map<String, Object?>;
expect(poll['name'], 'Lunch?');
});
test('Event.toJson leaves out an option custom entry named like an option key', () {
final event = Event(
type: EventType.pollUpdated,
poll: Poll(
id: 'poll-id',
name: 'Lunch?',
options: const [
PollOption(id: 'pizza', text: 'Pizza', custom: {'id': 'pasta'}),
],
),
);
final poll = event.toJson()['poll'] as Map<String, Object?>;
expect(poll['options'], [
{'id': 'pizza', 'text': 'Pizza'},
]);
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Split as suggested; stream_chat now runs 1,943 tests.

Comment on lines +1 to +5
import 'package:stream_chat/src/core/models/poll.dart';
import 'package:stream_chat/src/core/models/poll_option.dart';
import 'package:stream_chat/src/core/models/poll_vote.dart';
import 'package:stream_chat/src/core/models/user.dart';
import 'package:stream_chat/src/core/models/voting_visibility.dart';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Testing the exported API: every type imported here is exported from package:stream_chat/stream_chat.dart, so the src/ imports buy nothing and tie the test to file layout. Same in poll_option_test.dart and stream_poll_vote_list_controller_test.dart.

Suggested change
import 'package:stream_chat/src/core/models/poll.dart';
import 'package:stream_chat/src/core/models/poll_option.dart';
import 'package:stream_chat/src/core/models/poll_vote.dart';
import 'package:stream_chat/src/core/models/user.dart';
import 'package:stream_chat/src/core/models/voting_visibility.dart';
import 'package:stream_chat/stream_chat.dart';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Switched to the barrel import.

@@ -0,0 +1,45 @@
import 'package:stream_chat/src/core/models/poll_option.dart';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same as in poll_test.dart: import the package's public exports.

Suggested change
import 'package:stream_chat/src/core/models/poll_option.dart';
import 'package:stream_chat/stream_chat.dart';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Switched to the barrel import.

Comment on lines +3 to +5
import 'package:stream_chat/stream_chat.dart' hide Success;
import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart';
import 'package:stream_chat_flutter_core/src/stream_poll_vote_list_controller.dart';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same as in poll_test.dart: both are exported from package:stream_chat_flutter_core/stream_chat_flutter_core.dart, which also re-exports stream_chat, so this one import covers all three.

Suggested change
import 'package:stream_chat/stream_chat.dart' hide Success;
import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart';
import 'package:stream_chat_flutter_core/src/stream_poll_vote_list_controller.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Switched to the barrel import.

@xsahil03x xsahil03x left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A question on line width for docs and comments, separate from the first pass.

The new docs in this PR use two widths. The model docs (poll.dart, poll_vote.dart, voting_visibility.dart, the responses) wrap at 80. The client, channel, repository and mapper docs run to 120. Across packages/*/lib on v11, about 1% of /// lines go past 80, so 80 is what the codebase does in practice. Prose at 120 is also harder to read in IDE hovers and side-by-side diffs.

STYLE_GUIDE.md currently says comments and docs follow the 120 code limit, so nothing in this PR breaks a written rule. Should we change the guide to wrap docs and comments at 80 and keep 120 for code? If that sounds right, the threads below re-wrap the blocks in this PR that go past 80. They are suggestions, not requests: no wording changes except where an earlier thread already proposed one.

I've also folded the 80-column wrap into my earlier cursor threads on queryPolls / queryPollVotes and the _queryPollVotes comment, so each block has one suggestion.

Comment on lines +1432 to +1437
/// Creates [poll] and sends it to this channel in a new message.
///
/// Optionally provide a [messageText] to send a message along with the poll.
Future<SendMessageResponse> sendPoll(
/// The message carries [messageText] as its text, if given.
///
/// If the poll cannot be created, no message is sent and the failure is returned. If the message cannot be sent, the
/// poll stays created and the failure is returned.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would you be open to wrapping this at 80? The model docs in this PR (poll.dart, poll_vote.dart, the responses) already do, as does nearly every doc in lib/ (about 1% of /// lines go past 80). The client, channel and repository docs here run to 120.

Suggested change
/// Creates [poll] and sends it to this channel in a new message.
///
/// Optionally provide a [messageText] to send a message along with the poll.
Future<SendMessageResponse> sendPoll(
/// The message carries [messageText] as its text, if given.
///
/// If the poll cannot be created, no message is sent and the failure is returned. If the message cannot be sent, the
/// poll stays created and the failure is returned.
/// Creates [poll] and sends it to this channel in a new message.
///
/// The message carries [messageText] as its text, if given.
///
/// If the poll cannot be created, no message is sent and the failure is
/// returned. If the message cannot be sent, the poll stays created and the
/// failure is returned.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied. Docs and comments now wrap at 80 across the PR, and STYLE_GUIDE.md / CLAUDE.md say so.

Comment on lines +1459 to +1462
/// Updates [poll] to match the settings and options it carries.
///
/// [Poll.options] becomes the poll's full list of options, each matched by its id, so an existing option missing
/// from it is deleted. New options are added through [createPollOption].

@xsahil03x xsahil03x Oct 14, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same question about wrapping at 80 as above.

Also, should the doc say that enforceUniqueVote can't be changed here? As far as I can tell, an update keeps the value the poll was created with, so "match the settings it carries" isn't quite true for that one.

Suggested change
/// Updates [poll] to match the settings and options it carries.
///
/// [Poll.options] becomes the poll's full list of options, each matched by its id, so an existing option missing
/// from it is deleted. New options are added through [createPollOption].
/// Updates [poll] to match the settings and options it carries.
///
/// [Poll.options] becomes the poll's full list of options, each matched by
/// its id, so an existing option missing from it is deleted. New options are
/// added through [createPollOption].
///
/// [Poll.enforceUniqueVote] is left as the poll was created.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied as suggested.

Comment on lines +1495 to +1498
/// Votes for [option] of [poll], which was sent in [message].
///
/// If the poll allows only one vote per user, the vote replaces the previous one. The [option] must carry its id;
/// if it does not, the call returns a failure without voting.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same question about wrapping at 80 as above.

Suggested change
/// Votes for [option] of [poll], which was sent in [message].
///
/// If the poll allows only one vote per user, the vote replaces the previous one. The [option] must carry its id;
/// if it does not, the call returns a failure without voting.
/// Votes for [option] of [poll], which was sent in [message].
///
/// If the poll allows only one vote per user, the vote replaces the previous
/// one. The [option] must carry its id; if it does not, the call returns a
/// failure without voting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied as suggested.

Comment on lines +1540 to +1542
/// Removes [vote], a vote or answer on [poll], which was sent in [message].
///
/// The [vote] must carry its id; if it does not, the call returns a failure without removing anything.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same question about wrapping at 80 as above.

Suggested change
/// Removes [vote], a vote or answer on [poll], which was sent in [message].
///
/// The [vote] must carry its id; if it does not, the call returns a failure without removing anything.
/// Removes [vote], a vote or answer on [poll], which was sent in [message].
///
/// The [vote] must carry its id; if it does not, the call returns a failure
/// without removing anything.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied as suggested.

Comment on lines +1564 to +1567
/// Fetches one page of the votes and answers matching [filter] on the poll with the id [pollId], ordered by [sort].
///
/// Up to [limit] votes are returned. The next page is fetched by passing the `next` cursor of a response as [next],
/// and the previous page by passing its `prev` cursor as [prev]. At most one of [next] and [prev] may be given.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two things here. Effective Dart, DO use square brackets in doc comments to refer to in-scope identifiers: the backticked next / prev name the response's cursors, which can link. And, as elsewhere, would you be open to wrapping this at 80?

Suggested change
/// Fetches one page of the votes and answers matching [filter] on the poll with the id [pollId], ordered by [sort].
///
/// Up to [limit] votes are returned. The next page is fetched by passing the `next` cursor of a response as [next],
/// and the previous page by passing its `prev` cursor as [prev]. At most one of [next] and [prev] may be given.
/// Fetches one page of the votes and answers matching [filter] on the poll
/// with the id [pollId], ordered by [sort].
///
/// Up to [limit] votes are returned. The next page is fetched by passing a
/// response's [QueryPollVotesResponse.next] as [next], and the previous page
/// by passing its [QueryPollVotesResponse.prev] as [prev]. At most one of
/// [next] and [prev] may be given.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied as suggested.

return result.ignoreValue();
}

/// Casts a vote for an option of a poll sent in a message, identified by [optionId], [pollId] and [messageId].

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same question about wrapping at 80 as above.

Suggested change
/// Casts a vote for an option of a poll sent in a message, identified by [optionId], [pollId] and [messageId].
/// Casts a vote for an option of a poll sent in a message, identified by
/// [optionId], [pollId] and [messageId].

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied as suggested.

required String optionId,
}) => _castPollVote(messageId, pollId, api.VoteData(optionId: optionId));

/// Adds [answerText] as an answer to a poll sent in a message, identified by [pollId] and [messageId].

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same question about wrapping at 80 as above.

Suggested change
/// Adds [answerText] as an answer to a poll sent in a message, identified by [pollId] and [messageId].
/// Adds [answerText] as an answer to a poll sent in a message, identified by
/// [pollId] and [messageId].

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied as suggested.

return result.map((response) => response.toModel());
}

/// Removes a vote or answer from a poll sent in a message, identified by [voteId], [pollId] and [messageId].

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same question about wrapping at 80 as above.

Suggested change
/// Removes a vote or answer from a poll sent in a message, identified by [voteId], [pollId] and [messageId].
/// Removes a vote or answer from a poll sent in a message, identified by
/// [voteId], [pollId] and [messageId].

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied as suggested.

Comment on lines +134 to +136
/// Fetches one page of the polls matching [filter], ordered by [sort].
///
/// [next] and [prev] are the cursors a previous page returned; at most one of them may be given.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same question about wrapping at 80 as above.

Suggested change
/// Fetches one page of the polls matching [filter], ordered by [sort].
///
/// [next] and [prev] are the cursors a previous page returned; at most one of them may be given.
/// Fetches one page of the polls matching [filter], ordered by [sort].
///
/// [next] and [prev] are the cursors a previous page returned; at most one of
/// them may be given.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied as suggested.

Comment on lines +157 to +159
/// Fetches one page of the votes and answers matching [filter] on the poll with the id [pollId], ordered by [sort].
///
/// [next] and [prev] are the cursors a previous page returned; at most one of them may be given.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same question about wrapping at 80 as above.

Suggested change
/// Fetches one page of the votes and answers matching [filter] on the poll with the id [pollId], ordered by [sort].
///
/// [next] and [prev] are the cursors a previous page returned; at most one of them may be given.
/// Fetches one page of the votes and answers matching [filter] on the poll
/// with the id [pollId], ordered by [sort].
///
/// [next] and [prev] are the cursors a previous page returned; at most one of
/// them may be given.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied as suggested.


/// Updates the [poll] in this channel.
Future<UpdatePollResponse> updatePoll(Poll poll) {
Future<Result<SendMessageResponse>> _sendPollMessage(Poll poll, String? messageText) async {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we leave a TODO here? This helper only exists because sendMessage still throws. Once it returns a Result in the messages migration (group 10), sendPoll can call it directly from flatMapAsync, and the try/catch and the wrapping in StreamClientException go away.

Suggested change
Future<Result<SendMessageResponse>> _sendPollMessage(Poll poll, String? messageText) async {
// TODO(openapi-migration): remove once sendMessage returns a Result
Future<Result<SendMessageResponse>> _sendPollMessage(Poll poll, String? messageText) async {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added as suggested.

Comment on lines +5 to +8
/// Maps a [Sort] to the generated [api.SortParamRequest].
extension SortRequestMapper on Sort<Object> {
/// Converts this sort into an [api.SortParamRequest].
api.SortParamRequest toRequest() => api.SortParamRequest(field: field.remote, direction: direction.value);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could this follow the shape the feeds SDK uses (SortRequestExtension<T extends Object> on Sort<T>, with a block body)? Being generic over T means it doesn't rely on Sort<Poll> being a subtype of Sort<Object>, and it reads the same as the filter mapper suggested below.

And to keep filters consistent with sorts, could we add a filter_mapper.dart next to this one, as feeds does? Then the repositories call filter?.toRequest() next to sort.toRequest(), rather than one toJson() and one toRequest():

import 'package:stream_core/stream_core.dart' show Filter;

/// Maps a [Filter] to the filter of a generated request.
extension FilterRequestMapper<T extends Object> on Filter<T> {
  /// Converts this filter into the map a generated request takes.
  Map<String, Object?> toRequest() => toJson();
}
Suggested change
/// Maps a [Sort] to the generated [api.SortParamRequest].
extension SortRequestMapper on Sort<Object> {
/// Converts this sort into an [api.SortParamRequest].
api.SortParamRequest toRequest() => api.SortParamRequest(field: field.remote, direction: direction.value);
/// Maps a [Sort] to the generated [api.SortParamRequest].
extension SortRequestMapper<T extends Object> on Sort<T> {
/// Converts this sort into an [api.SortParamRequest].
api.SortParamRequest toRequest() {
return api.SortParamRequest(
field: field.remote,
direction: direction.value,
);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied, and added filter_mapper.dart with your FilterRequestMapper; both queries now call filter?.toRequest().

Comment on lines +12 to +14
import 'mapper/polls_mapper.dart';
import 'mapper/result_mapper.dart';
import 'mapper/sort_mapper.dart';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If we add filter_mapper.dart, the import goes here.

Suggested change
import 'mapper/polls_mapper.dart';
import 'mapper/result_mapper.dart';
import 'mapper/sort_mapper.dart';
import 'mapper/filter_mapper.dart';
import 'mapper/polls_mapper.dart';
import 'mapper/result_mapper.dart';
import 'mapper/sort_mapper.dart';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied.

}) async {
final result = await _api.queryPolls(
queryPollsRequest: api.QueryPollsRequest(
filter: filter?.toJson(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

With the filter mapper, this matches the sort line below it.

Suggested change
filter: filter?.toJson(),
filter: filter?.toRequest(),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied with the filter mapper.

final result = await _api.queryPollVotes(
pollId: pollId,
queryPollVotesRequest: api.QueryPollVotesRequest(
filter: filter?.toJson(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same as in queryPolls.

Suggested change
filter: filter?.toJson(),
filter: filter?.toRequest(),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied with the filter mapper.

Comment on lines +69 to +77
/// The unique identifier of the user who cast this vote.
///
/// Null on someone else's vote in an anonymous poll.
@override
final String? userId;

/// The user who casted the vote.
@JsonKey(includeToJson: false)
/// The user who cast this vote.
///
/// Null on someone else's vote in an anonymous poll.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is "someone else's vote" accurate here? As far as I can tell, in an anonymous poll the vote returned by castPollVote / removePollVote comes back without a user even when it's the current user's own. Answers in Poll.latestAnswers keep their user. Saying "may be null" seems safer than naming which votes.

Suggested change
/// The unique identifier of the user who cast this vote.
///
/// Null on someone else's vote in an anonymous poll.
@override
final String? userId;
/// The user who casted the vote.
@JsonKey(includeToJson: false)
/// The user who cast this vote.
///
/// Null on someone else's vote in an anonymous poll.
/// The unique identifier of the user who cast this vote.
///
/// May be null in an anonymous poll, even on the current user's own vote.
@override
final String? userId;
/// The user who cast this vote.
///
/// May be null in an anonymous poll, even on the current user's own vote.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied, thanks.

Comment on lines +93 to +94
/// The maximum number of options each user may vote for, or null for no
/// limit.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Could we mention that the limit only takes effect when enforceUniqueVote is false? Since enforceUniqueVote defaults to true, a poll created with only a maxVotesAllowed behaves as one vote per user, and this field is where someone would look to find out why.

Suggested change
/// The maximum number of options each user may vote for, or null for no
/// limit.
/// The maximum number of options each user may vote for, or null for no
/// limit.
///
/// Applies only when [enforceUniqueVote] is false.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied, thanks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@VelikovPetar

Copy link
Copy Markdown
Contributor Author

First pass, limited to STYLE_GUIDE.md, EFFECTIVE_DART_DOC.md and TESTING.md

Thanks! Every suggestion is applied in 92675e608, plus the filter mapper and the move of the poll client tests to client_polls_test.dart. stream_chat passes 1,943 tests (the Event.toJson split adds two).

@VelikovPetar

Copy link
Copy Markdown
Contributor Author

Should we change the guide to wrap docs and comments at 80 and keep 120 for code?

Agreed. STYLE_GUIDE.md and CLAUDE.md now say docs and comments wrap at 80 while code stays at 120, and every re-wrap suggestion is applied. No comment line the PR adds goes past 80.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants