diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index d6f7bc437..49f0075e7 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -1,8 +1,7 @@ import 'dart:async'; import 'dart:math'; -import 'package:collection/collection.dart' - show IterableExtension, ListEquality; +import 'package:collection/collection.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/src/client/retry_queue.dart'; import 'package:stream_chat/src/core/util/utils.dart'; @@ -1800,6 +1799,24 @@ class ChannelClientState { _listenReactionDeleted(); + /* Start of poll events */ + + _listenPollUpdated(); + + _listenPollClosed(); + + _listenPollAnswerCasted(); + + _listenPollVoteCasted(); + + _listenPollVoteChanged(); + + _listenPollAnswerRemoved(); + + _listenPollVoteRemoved(); + + /* End of poll events */ + _listenReadEvents(); _listenUnreadEvents(); @@ -2052,6 +2069,198 @@ class ChannelClientState { _retryQueue.add(failedMessages); } + Message? _findPollMessage(String pollId) { + final message = messages.firstWhereOrNull((it) => it.pollId == pollId); + if (message != null) return message; + + final threadMessage = threads.values.flattened.firstWhereOrNull((it) { + return it.pollId == pollId; + }); + + return threadMessage; + } + + void _listenPollUpdated() { + _subscriptions.add(_channel.on(EventType.pollUpdated).listen((event) { + final eventPoll = event.poll; + if (eventPoll == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final answers = oldPoll?.answers ?? eventPoll.answers; + final ownVotesAndAnswers = + oldPoll?.ownVotesAndAnswers ?? eventPoll.ownVotesAndAnswers; + + final poll = eventPoll.copyWith( + answers: answers, + ownVotesAndAnswers: ownVotesAndAnswers, + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + })); + } + + void _listenPollClosed() { + _subscriptions.add(_channel.on(EventType.pollClosed).listen((event) { + final eventPoll = event.poll; + if (eventPoll == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + final poll = oldPoll?.copyWith(isClosed: true) ?? eventPoll; + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + })); + } + + void _listenPollAnswerCasted() { + _subscriptions.add(_channel.on(EventType.pollAnswerCasted).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final answers = { + for (final ans in oldPoll?.answers ?? []) ans.id: ans, + eventPollVote.id!: eventPollVote, + }; + + final currentUserId = _channel.client.state.currentUser?.id; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + if (eventPollVote.userId == currentUserId) + eventPollVote.id!: eventPollVote, + }; + + final poll = eventPoll.copyWith( + answers: [...answers.values], + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + })); + } + + void _listenPollVoteCasted() { + _subscriptions.add(_channel.on(EventType.pollVoteCasted).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final answers = oldPoll?.answers ?? eventPoll.answers; + final currentUserId = _channel.client.state.currentUser?.id; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + if (eventPollVote.userId == currentUserId) + eventPollVote.id!: eventPollVote, + }; + + final poll = eventPoll.copyWith( + answers: answers, + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + })); + } + + void _listenPollAnswerRemoved() { + _subscriptions.add(_channel.on(EventType.pollAnswerRemoved).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final answers = { + for (final ans in oldPoll?.answers ?? []) ans.id: ans, + }..remove(eventPollVote.id); + + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + }..remove(eventPollVote.id); + + final poll = eventPoll.copyWith( + answers: [...answers.values], + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + })); + } + + void _listenPollVoteRemoved() { + _subscriptions.add(_channel.on(EventType.pollVoteRemoved).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final answers = oldPoll?.answers ?? eventPoll.answers; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + }..remove(eventPollVote.id); + + final poll = eventPoll.copyWith( + answers: answers, + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + })); + } + + void _listenPollVoteChanged() { + _subscriptions.add(_channel.on(EventType.pollVoteChanged).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; + + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; + + final oldPoll = pollMessage.poll; + + final answers = oldPoll?.answers ?? eventPoll.answers; + final currentUserId = _channel.client.state.currentUser?.id; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + if (eventPollVote.userId == currentUserId) + eventPollVote.id!: eventPollVote, + }; + + final poll = eventPoll.copyWith( + answers: answers, + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + })); + } + void _listenReactionDeleted() { _subscriptions.add(_channel.on(EventType.reactionDeleted).listen((event) { final oldMessage = diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 11e91a03b..b0c1534d8 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -200,7 +200,17 @@ class StreamChatClient { /// Stream of [Event] coming from [_ws] connection /// Listen to this or use the [on] method to filter specific event types - Stream get eventStream => _eventController.stream; + Stream get eventStream => _eventController.stream.map( + // If the poll vote is an answer, we should emit a different event + // to make it easier to handle in the state. + (event) => switch ((event.type, event.pollVote?.isAnswer == true)) { + (EventType.pollVoteCasted || EventType.pollVoteChanged, true) => + event.copyWith(type: EventType.pollAnswerCasted), + (EventType.pollVoteRemoved, true) => + event.copyWith(type: EventType.pollAnswerRemoved), + _ => event, + }, + ); final _wsConnectionStatusController = BehaviorSubject.seeded(ConnectionStatus.disconnected); diff --git a/packages/stream_chat/lib/src/core/models/event.dart b/packages/stream_chat/lib/src/core/models/event.dart index 50c41cbb3..9200578a5 100644 --- a/packages/stream_chat/lib/src/core/models/event.dart +++ b/packages/stream_chat/lib/src/core/models/event.dart @@ -180,6 +180,8 @@ class Event { OwnUser? me, User? user, Message? message, + Poll? poll, + PollVote? pollVote, EventChannel? channel, Member? member, Reaction? reaction, @@ -201,6 +203,8 @@ class Event { me: me ?? this.me, user: user ?? this.user, message: message ?? this.message, + poll: poll ?? this.poll, + pollVote: pollVote ?? this.pollVote, totalUnreadCount: totalUnreadCount ?? this.totalUnreadCount, unreadChannels: unreadChannels ?? this.unreadChannels, reaction: reaction ?? this.reaction, diff --git a/packages/stream_chat/lib/src/core/models/poll.dart b/packages/stream_chat/lib/src/core/models/poll.dart index 358716104..b95122c74 100644 --- a/packages/stream_chat/lib/src/core/models/poll.dart +++ b/packages/stream_chat/lib/src/core/models/poll.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:stream_chat/src/core/models/poll_option.dart'; @@ -34,19 +35,18 @@ class Poll extends Equatable { this.enforceUniqueVote = false, this.maxVotesAllowed, this.allowAnswers = false, - this.latestAnswers = const [], + this.answers = const [], this.answersCount = 0, this.allowUserSuggestedOptions = false, this.isClosed = false, DateTime? createdAt, DateTime? updatedAt, this.voteCountsByOption = const {}, - this.votes = const [], this.voteCount = 0, - this.latestVotesByOption = const {}, + this.votesByOption = const {}, this.createdById, this.createdBy, - this.ownVotes = const [], + this.ownVotesAndAnswers = const [], this.extraData = const {}, }) : id = id ?? const Uuid().v4(), createdAt = createdAt ?? DateTime.now(), @@ -94,34 +94,40 @@ class Poll extends Equatable { /// Indicates if the poll is closed. final bool isClosed; + /// The total number of answers received by the poll. + @JsonKey(includeToJson: false) + final int answersCount; + /// Map of vote counts by option. @JsonKey(includeToJson: false) final Map voteCountsByOption; + /// Map of latest votes by option. + @JsonKey(name: 'latest_votes_by_option', includeToJson: false) + final Map> votesByOption; + /// List of votes received by the poll. - @JsonKey(includeToJson: false) - final List votes; + /// + /// Note: This does not include the answers provided by the users, + /// see [answers] for that. + List get votes => [ + ...votesByOption.values.flattened.where((it) => !it.isAnswer), + ]; + + /// List of latest answers received by the poll. + @JsonKey(name: 'latest_answers', includeToJson: false) + final List answers; /// List of votes casted by the current user. - @JsonKey(includeToJson: false) - final List ownVotes; + /// + /// Contains both votes and answers. + @JsonKey(name: 'own_votes', includeToJson: false) + final List ownVotesAndAnswers; /// The total number of votes received by the poll. @JsonKey(includeToJson: false) final int voteCount; - /// The total number of answers received by the poll. - @JsonKey(includeToJson: false) - final int answersCount; - - /// Map of latest votes by option. - @JsonKey(includeToJson: false) - final Map> latestVotesByOption; - - /// List of latest answers received by the poll. - @JsonKey(includeToJson: false) - final List latestAnswers; - /// The id of the user who created the poll. @JsonKey(includeToJson: false) final String? createdById; @@ -145,6 +151,55 @@ class Poll extends Equatable { Map toJson() => Serializer.moveFromExtraDataToRoot(_$PollToJson(this)); + /// Creates a copy of [Poll] with specified attributes overridden. + Poll copyWith({ + String? id, + String? name, + String? description, + List? options, + VotingVisibility? votingVisibility, + bool? enforceUniqueVote, + int? maxVotesAllowed, + bool? allowUserSuggestedOptions, + bool? allowAnswers, + bool? isClosed, + Map? voteCountsByOption, + List? ownVotesAndAnswers, + int? voteCount, + int? answersCount, + Map>? votesByOption, + List? answers, + String? createdById, + User? createdBy, + DateTime? createdAt, + DateTime? updatedAt, + Map? extraData, + }) => + Poll( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + options: options ?? this.options, + votingVisibility: votingVisibility ?? this.votingVisibility, + enforceUniqueVote: enforceUniqueVote ?? this.enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed ?? this.maxVotesAllowed, + allowUserSuggestedOptions: + allowUserSuggestedOptions ?? this.allowUserSuggestedOptions, + allowAnswers: allowAnswers ?? this.allowAnswers, + isClosed: isClosed ?? this.isClosed, + voteCountsByOption: voteCountsByOption ?? this.voteCountsByOption, + ownVotesAndAnswers: ownVotesAndAnswers ?? this.ownVotesAndAnswers, + voteCount: voteCount ?? this.voteCount, + answersCount: answersCount ?? this.answersCount, + votesByOption: votesByOption ?? this.votesByOption, + answers: answers ?? this.answers, + createdById: createdById ?? this.createdById, + createdBy: createdBy ?? this.createdBy, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + extraData: extraData ?? this.extraData, + ); + /// Known top level fields. /// /// Useful for [Serializer] methods. @@ -185,15 +240,74 @@ class Poll extends Equatable { allowAnswers, isClosed, voteCountsByOption, - votes, - ownVotes, + ownVotesAndAnswers, voteCount, answersCount, - latestVotesByOption, - latestAnswers, + votesByOption, + answers, createdById, createdBy, createdAt, updatedAt, ]; } + +/// Helper extension for [Poll] model. +extension PollX on Poll { + /// The value of the option with the most votes. + int get currentMaximumVoteCount => voteCountsByOption.values.maxOrNull ?? 0; + + /// Whether the poll is already closed and the provided option is the one, + /// and **the only one** with the most votes. + bool isOptionWinner(PollOption option) => + isClosed && isOptionWithMostVotes(option); + + /// Whether the poll is already closed and the provided option is one of that + /// has the most votes. + bool isOptionOneOfTheWinners(PollOption option) => + isClosed && isOptionWithMaximumVotes(option); + + /// Whether the provided option is the one, and **the only one** with the most + /// votes. + bool isOptionWithMostVotes(PollOption option) { + final optionsWithMostVotes = { + for (final entry in voteCountsByOption.entries) + if (entry.value == currentMaximumVoteCount) entry.key: entry.value + }; + + return optionsWithMostVotes.length == 1 && + optionsWithMostVotes[option.id] != null; + } + + /// Whether the provided option is one of that has the most votes. + bool isOptionWithMaximumVotes(PollOption option) { + final optionsWithMostVotes = { + for (final entry in voteCountsByOption.entries) + if (entry.value == currentMaximumVoteCount) entry.key: entry.value + }; + + return optionsWithMostVotes[option.id] != null; + } + + /// The vote count for the given option. + int voteCountFor(PollOption option) => voteCountsByOption[option.id] ?? 0; + + /// The ratio of the votes for the given option in comparison with the number + /// of total votes. + double voteRatioFor(PollOption option) { + if (currentMaximumVoteCount == 0) return 0; + + final optionVoteCount = voteCountFor(option); + return optionVoteCount / currentMaximumVoteCount; + } + + /// Returns the vote of the current user for the given option in case the user + /// has voted. + PollVote? currentUserVoteFor(PollOption option) => + ownVotesAndAnswers.firstWhereOrNull((it) => it.optionId == option.id); + + /// Returns a Boolean value indicating whether the current user has voted the + /// given option. + bool hasCurrentUserVotedFor(PollOption option) => + ownVotesAndAnswers.any((it) => it.optionId == option.id); +} diff --git a/packages/stream_chat/lib/src/core/models/poll.g.dart b/packages/stream_chat/lib/src/core/models/poll.g.dart index 857cccbfb..883e82bae 100644 --- a/packages/stream_chat/lib/src/core/models/poll.g.dart +++ b/packages/stream_chat/lib/src/core/models/poll.g.dart @@ -19,7 +19,7 @@ Poll _$PollFromJson(Map json) => Poll( enforceUniqueVote: json['enforce_unique_vote'] as bool? ?? false, maxVotesAllowed: (json['max_votes_allowed'] as num?)?.toInt(), allowAnswers: json['allow_answers'] as bool? ?? false, - latestAnswers: (json['latest_answers'] as List?) + answers: (json['latest_answers'] as List?) ?.map((e) => PollVote.fromJson(e as Map)) .toList() ?? const [], @@ -38,26 +38,21 @@ Poll _$PollFromJson(Map json) => Poll( (k, e) => MapEntry(k, (e as num).toInt()), ) ?? const {}, - votes: (json['votes'] as List?) - ?.map((e) => PollVote.fromJson(e as Map)) - .toList() ?? - const [], voteCount: (json['vote_count'] as num?)?.toInt() ?? 0, - latestVotesByOption: - (json['latest_votes_by_option'] as Map?)?.map( - (k, e) => MapEntry( - k, - (e as List) - .map( - (e) => PollVote.fromJson(e as Map)) - .toList()), - ) ?? - const {}, + votesByOption: (json['latest_votes_by_option'] as Map?) + ?.map( + (k, e) => MapEntry( + k, + (e as List) + .map((e) => PollVote.fromJson(e as Map)) + .toList()), + ) ?? + const {}, createdById: json['created_by_id'] as String?, createdBy: json['created_by'] == null ? null : User.fromJson(json['created_by'] as Map), - ownVotes: (json['own_votes'] as List?) + ownVotesAndAnswers: (json['own_votes'] as List?) ?.map((e) => PollVote.fromJson(e as Map)) .toList() ?? const [], diff --git a/packages/stream_chat/lib/src/event_type.dart b/packages/stream_chat/lib/src/event_type.dart index a430acf78..dc81918c0 100644 --- a/packages/stream_chat/lib/src/event_type.dart +++ b/packages/stream_chat/lib/src/event_type.dart @@ -125,15 +125,21 @@ class EventType { /// Event sent when a poll is updated. static const String pollUpdated = 'poll.updated'; + /// Event sent when a answer is casted on a poll. + static const String pollAnswerCasted = 'poll.answer_casted'; + /// Event sent when a vote is casted on a poll. static const String pollVoteCasted = 'poll.vote_casted'; /// Event sent when a vote is changed on a poll. static const String pollVoteChanged = 'poll.vote_changed'; - /// Event sent when a vote is removed on a poll. + /// Event sent when a vote is removed from a poll. static const String pollVoteRemoved = 'poll.vote_removed'; + /// Event sent when a answer is removed from a poll. + static const String pollAnswerRemoved = 'poll.answer_removed'; + /// Event sent when a poll is closed. static const String pollClosed = 'poll.closed'; } diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 9b0a8967a..619c323a5 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1999,16 +1999,26 @@ void main() { test('`.castPollVote`', () async { const messageId = 'test-message-id'; const pollId = 'test-poll-id'; - final vote = PollVote(optionId: 'test-option-id'); + const optionId = 'test-option-id'; + final vote = PollVote(optionId: optionId); + + // Custom matcher to check if the Vote object has the specified id + Matcher matchesVoteOption(String expected) => predicate( + (vote) => vote.optionId == expected, + 'Vote with option $expected', + ); - when(() => api.polls.castPollVote(messageId, pollId, vote)) + when(() => api.polls.castPollVote( + messageId, pollId, any(that: matchesVoteOption(optionId)))) .thenAnswer((_) async => CastPollVoteResponse()..vote = vote); - final res = await client.castPollVote(messageId, pollId, vote); + final res = + await client.castPollVote(messageId, pollId, optionId: optionId); expect(res, isNotNull); expect(res.vote, vote); - verify(() => api.polls.castPollVote(messageId, pollId, vote)).called(1); + verify(() => api.polls.castPollVote( + messageId, pollId, any(that: matchesVoteOption(optionId)))).called(1); verifyNoMoreInteractions(api.polls); }); diff --git a/packages/stream_chat/test/src/core/models/poll_test.dart b/packages/stream_chat/test/src/core/models/poll_test.dart index 443e5edf8..af7907d9d 100644 --- a/packages/stream_chat/test/src/core/models/poll_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_test.dart @@ -29,10 +29,10 @@ void main() { expect(option.id, 'option1'); expect(option.text, 'option1 text'); - expect(poll.latestVotesByOption, isEmpty); + expect(poll.votesByOption, isEmpty); - expect(poll.ownVotes.length, 1); - final vote = poll.ownVotes[0]; + expect(poll.ownVotesAndAnswers.length, 1); + final vote = poll.ownVotesAndAnswers[0]; expect(vote.id, 'luke_skywalker'); expect(vote.optionId, 'option1'); expect(vote.pollId, '7fd88eb3-fc05-4e89-89af-36c6d8995dda');