Repository navigation
refactor(llc)!: route polls through the generated client - #3037
VelikovPetar wants to merge 27 commits into
Conversation
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
…/flu-882_polls_openapi_migration
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
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
…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>
# Conflicts: # packages/stream_chat/lib/src/ws/events/event.g.dart
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
# 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>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
| /// | ||
| /// A visibility without a constant here can still be named by wrapping its | ||
| /// value: `VotingVisibility('public')`. | ||
| extension type const VotingVisibility(String rawType) implements String { |
There was a problem hiding this comment.
We can make the rawType private as we already implement String
There was a problem hiding this comment.
Maybe also check for similar enums in the SDK
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
| /// 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')`. |
There was a problem hiding this comment.
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.
| /// 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')`. |
| /// 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'); |
There was a problem hiding this comment.
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.
| /// 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'); |
| final List<PollOption> options; | ||
|
|
||
| /// Represents the visibility of the voting process. | ||
| /// Who can see which option each user voted for. |
There was a problem hiding this comment.
Effective Dart, PREFER starting a non-boolean variable or property comment with a noun phrase: "Who can see…" is a clause.
| /// Who can see which option each user voted for. | |
| /// The rule for who can see which option each user voted for. |
| final bool allowUserSuggestedOptions; | ||
|
|
||
| /// If true, users can provide their own answers/comments. | ||
| /// Whether users may leave a free-form answer. |
There was a problem hiding this comment.
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.
| /// Whether users may leave a free-form answer. | |
| /// Whether users may add a free-form answer. |
|
|
||
| /// The total number of answers received by the poll. | ||
| @JsonKey(includeToJson: false) | ||
| /// The total number of answers left on this poll. |
There was a problem hiding this comment.
Same as on allowAnswers: "left" can read as "remaining".
| /// The total number of answers left on this poll. | |
| /// The total number of answers added to this poll. |
| expect(res.getOrNull()?.app.fileUploadConfig.sizeLimit, UploadConfig.defaultSizeLimit); | ||
| }); | ||
|
|
||
| test('StreamChatClient.createPoll sends the poll settings and returns the created poll', () async { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| 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, | ||
| }); | ||
| }); |
There was a problem hiding this comment.
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.
| 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'}, | |
| ]); | |
| }); |
There was a problem hiding this comment.
Split as suggested; stream_chat now runs 1,943 tests.
| 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'; |
There was a problem hiding this comment.
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.
| 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'; |
There was a problem hiding this comment.
Switched to the barrel import.
| @@ -0,0 +1,45 @@ | |||
| import 'package:stream_chat/src/core/models/poll_option.dart'; | |||
There was a problem hiding this comment.
Same as in poll_test.dart: import the package's public exports.
| import 'package:stream_chat/src/core/models/poll_option.dart'; | |
| import 'package:stream_chat/stream_chat.dart'; |
There was a problem hiding this comment.
Switched to the barrel import.
| 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'; |
There was a problem hiding this comment.
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.
| 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'; |
There was a problem hiding this comment.
Switched to the barrel import.
xsahil03x
left a comment
There was a problem hiding this comment.
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.
| /// 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. |
There was a problem hiding this comment.
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.
| /// 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. |
There was a problem hiding this comment.
Applied. Docs and comments now wrap at 80 across the PR, and STYLE_GUIDE.md / CLAUDE.md say so.
| /// 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]. |
There was a problem hiding this comment.
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.
| /// 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. |
There was a problem hiding this comment.
Applied as suggested.
| /// 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. |
There was a problem hiding this comment.
Same question about wrapping at 80 as above.
| /// 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. |
There was a problem hiding this comment.
Applied as suggested.
| /// 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. |
There was a problem hiding this comment.
Same question about wrapping at 80 as above.
| /// 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. |
There was a problem hiding this comment.
Applied as suggested.
| /// 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. |
There was a problem hiding this comment.
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?
| /// 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. |
There was a problem hiding this comment.
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]. |
There was a problem hiding this comment.
Same question about wrapping at 80 as above.
| /// 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]. |
There was a problem hiding this comment.
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]. |
There was a problem hiding this comment.
Same question about wrapping at 80 as above.
| /// 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]. |
There was a problem hiding this comment.
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]. |
There was a problem hiding this comment.
Same question about wrapping at 80 as above.
| /// 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]. |
There was a problem hiding this comment.
Applied as suggested.
| /// 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. |
There was a problem hiding this comment.
Same question about wrapping at 80 as above.
| /// 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. |
There was a problem hiding this comment.
Applied as suggested.
| /// 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. |
There was a problem hiding this comment.
Same question about wrapping at 80 as above.
| /// 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. |
There was a problem hiding this comment.
Applied as suggested.
|
|
||
| /// Updates the [poll] in this channel. | ||
| Future<UpdatePollResponse> updatePoll(Poll poll) { | ||
| Future<Result<SendMessageResponse>> _sendPollMessage(Poll poll, String? messageText) async { |
There was a problem hiding this comment.
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.
| 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 { |
There was a problem hiding this comment.
Added as suggested.
| /// 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); |
There was a problem hiding this comment.
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();
}| /// 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, | |
| ); | |
| } |
There was a problem hiding this comment.
Applied, and added filter_mapper.dart with your FilterRequestMapper; both queries now call filter?.toRequest().
| import 'mapper/polls_mapper.dart'; | ||
| import 'mapper/result_mapper.dart'; | ||
| import 'mapper/sort_mapper.dart'; |
There was a problem hiding this comment.
If we add filter_mapper.dart, the import goes here.
| 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'; |
| }) async { | ||
| final result = await _api.queryPolls( | ||
| queryPollsRequest: api.QueryPollsRequest( | ||
| filter: filter?.toJson(), |
There was a problem hiding this comment.
With the filter mapper, this matches the sort line below it.
| filter: filter?.toJson(), | |
| filter: filter?.toRequest(), |
There was a problem hiding this comment.
Applied with the filter mapper.
| final result = await _api.queryPollVotes( | ||
| pollId: pollId, | ||
| queryPollVotesRequest: api.QueryPollVotesRequest( | ||
| filter: filter?.toJson(), |
There was a problem hiding this comment.
Same as in queryPolls.
| filter: filter?.toJson(), | |
| filter: filter?.toRequest(), |
There was a problem hiding this comment.
Applied with the filter mapper.
| /// 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. |
There was a problem hiding this comment.
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.
| /// 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. |
There was a problem hiding this comment.
Applied, thanks.
| /// The maximum number of options each user may vote for, or null for no | ||
| /// limit. |
There was a problem hiding this comment.
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.
| /// 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. |
There was a problem hiding this comment.
Applied, thanks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Thanks! Every suggestion is applied in |
Agreed. |
Submit a pull request
Linear: FLU-882
Github Issue: -
CLA
Description of the pull request
Routes the poll endpoints through the generated OpenAPI client and turns
Poll,PollOptionandPollVoteinto domain models. Every poll call onStreamChatClientandChannelnow returns aResult.Poll,PollOptionandPollVoteare@freezedwith no JSON;VotingVisibilityis an extension type.PollsRepositoryandpolls_mapper.dartmap the generated types to the public models.Message.poll,Event.pollandEvent.pollVotekeep decoding the v1 / WebSocket JSON through dedicated v1 converters.DraftMessage.pollcarries the same converter only so the generated code compiles: a draft payload haspoll_idonly, and the field is set by constructors alone (left for group 07).Channel.sendPollreturnsResult<SendMessageResponse>.Channel.castPollVote/removePollVotewith an option or vote that has no id returnFailure(StreamClientException)instead of throwing anArgumentError.limit(default 10),nextandprev;previs exposed on the responses.PollOptionthroughfromData/toDataand keeps an unknownVotingVisibility(schema bump).StreamPollVoteListControllerfolds theResult.PollsApi,StreamChatApi.pollsand the v1 poll DTOs.PollResponseDataPollPollOptionResponseDataPollOptionPollVoteResponseDataPollVotePollResponsePollResponsePollOptionResponsePollOptionResponsePollVoteResponsePollVoteResponseQueryPollsResponseQueryPollsResponsePollVotesResponseQueryPollVotesResponsePollsApiPollsRepositoryMigration plan and skill
copyWithrule:Poll,PollOptionandPollVoteuse freezed's generatedcopyWith. It differs from v10's hand-written one:nullclears a nullable field where v10 kept it, andnullforPoll.id,createdAtorupdatedAtre-applies the constructor default. Theopenapi-migrationskill gainscopywith-audit.mdandscripts/copywith_audit.py, which probe a model with freezed'scopyWithand list every call site whose behaviour or compilation changes; README rule 2 and the definition of done now ask for that audit before a v10copyWithis deleted, and say when freezed's cannot replace it (a parameter backed by a getter, a private field or a different type, as onMessage). On the poll models the audit found one lib site (stream_message_preview_text.dart) and one test per model; the event merging inchannel_state_mutations.dartandPoll.withTranslationsOfnever passnullwhile the field is set, so they are unchanged.generate_plan.py, and05-polls.mdis regenerated from it.custom, the wire'scustom, verbatim, in both directions, and that a typed wire field is never parked inextraDatabehind a getter. The polls mapper follows it:Poll.extraData/PollOption.extraDataare the generatedcustommap as is, and the requests sendextraDataascustom; 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 promoteUser.name/imageand theChannelModelflags (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.User.extraDatadifference 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:
limit,nextandprevonly; the v10offset,id_*andcreated_at_*parameters were always ignored.PollandPollOptionequality now includesextraData, so aMessagecarrying such a poll compares differently.Poll.extraDataandPollOption.extraDataare renamedcustom(a hard rename, no alias;StreamPollController.addOption/updateOptionfollow), the first models to take the wire's name. They are the wire'scustommap 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'stext) 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 inextraDatawhen such an entry exists, since v1's flat JSON cannot carry both.Poll.topLevelFieldsandPollOption.topLevelFieldsare removed; the v1 converter owns the key lists.Poll.copyWith,PollOption.copyWithandPollVote.copyWithare freezed's: passingnullclears a nullable field (v10 kept it, exceptPoll.maxVotesAllowedandPollOption.id, which already cleared) and re-applies theidand timestamp defaults.updatePoll/updatePollOptionalways send the request: an option without an id goes out with an empty one (the generated request types require aString), and the API rejects it, as it did in v10. The SDK doesn't pre-check a rule the backend owns.Poll/PollOption.createPoll,updatePoll,createPollOptionandupdatePollOptionstill take the full model, and the mapper sends only the fields the request declares. Server-owned fields such asvoteCount,latestVotesByOption,ownVotesAndAnswers,createdByand the timestamps are not sent. The alternative was dedicated request models undermodels/request/(for example aCreatePollRequestholding 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, includingStreamPollController, which builds aPoll. v10 didn't send these fields either, sincetoJsonexcluded them.limit/next/prev(limit still 10), withprevon the responses.Poll.latestVotes,ownVotesandownAnswersstay cachedlate finallists, 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==,hashCodeandtoString; they are derived fromlatestVotesByOptionandownVotesAndAnswers, which are compared already, so equality is unchanged.Poll.nameI18n/descriptionI18n,PollOption.textI18n,PollVote.answerTextI18n), mapped from the generated responses and from v1 JSON alike, and stay out ofextraData. They are never sent back to the API; the offline cache keeps them.createPoll/sendPollwith a client-set option id: the generatedPollOptionInputhas noid, 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.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 aFailurenobody reads. Surfacing these in the UI is left for a follow-up.Testing
stream_chat1,943,stream_chat_persistence314 andstream_chat_flutter_core384 tests pass, with new client, channel, v1-decoding, model and controller tests. The poll client tests live inclient_polls_test.dart.Message.polldecoding gave identical results, except:createdByand a vote'suserno longer carryblocked_user_ids,deleted_at,deactivated_atorrevoke_tokens_issued_beforeinextraData. The generatedUserResponsetypes them andUserdoesn'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 onlysendPoll's local message holds a REST poll, until the server's reply replaces it. Tracked for theUserrestructure in group 09 of the plan.customchanges (verbatim custom data,extraData→custom,polls.customcolumn): 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 ofownVotesAndAnswers). A probe confirmed the backend stores custom keys named like a poll's or option's own fields (name,is_closed, an option'stextandid) 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.customcolumn, options stored with acustomkey) and rendered both polls of the test channel from the cache after a relaunch with Wi-Fi and data off.Screenshots / Videos
N/A: no UI change.
🤖 Generated with Claude Code