|
| 1 | +import 'dart:collection'; |
| 2 | + |
| 3 | +import 'package:collection/collection.dart'; |
1 | 4 | import 'package:json_annotation/json_annotation.dart';
|
2 | 5 |
|
3 | 6 | part 'reaction.g.dart';
|
4 | 7 |
|
5 |
| -/// As in [Message.reactions]. |
| 8 | +/// A message's reactions, in a convenient data structure. |
| 9 | +class Reactions { |
| 10 | + int get total => _total; |
| 11 | + int _total; |
| 12 | + |
| 13 | + /// A list of [ReactionWithVotes] objects. |
| 14 | + /// |
| 15 | + /// There won't be two items with the same |
| 16 | + /// [ReactionWithVotes.reactionType] and [ReactionWithVotes.emojiCode]. |
| 17 | + /// (We don't also key on [ReactionWithVotes.emojiName]; |
| 18 | + /// see [ReactionWithVotes].) |
| 19 | + /// |
| 20 | + /// Sorted descending by the size of [ReactionWithVotes.userIds], |
| 21 | + /// i.e., the number of votes. |
| 22 | + late final List<ReactionWithVotes> aggregated; |
| 23 | + |
| 24 | + Reactions._(this.aggregated, this._total); |
| 25 | + |
| 26 | + factory Reactions(List<Reaction> unaggregated) { |
| 27 | + final byReaction = LinkedHashMap<Reaction, ReactionWithVotes>( |
| 28 | + equals: (a, b) => a.reactionType == b.reactionType && a.emojiCode == b.emojiCode, |
| 29 | + hashCode: (r) => Object.hash(r.reactionType, r.emojiCode), |
| 30 | + ); |
| 31 | + for (final reaction in unaggregated) { |
| 32 | + final current = byReaction[reaction] ??= ReactionWithVotes.empty(reaction); |
| 33 | + current.userIds.add(reaction.userId); |
| 34 | + } |
| 35 | + |
| 36 | + return Reactions._( |
| 37 | + byReaction.values.sorted( |
| 38 | + // Descending by number of votes |
| 39 | + (a, b) => -a.userIds.length.compareTo(b.userIds.length), |
| 40 | + ), |
| 41 | + unaggregated.length, |
| 42 | + ); |
| 43 | + } |
| 44 | + |
| 45 | + factory Reactions.fromJson(List<dynamic> json) { |
| 46 | + return Reactions( |
| 47 | + json.map((r) => Reaction.fromJson(r as Map<String, dynamic>)).toList(), |
| 48 | + ); |
| 49 | + } |
| 50 | + |
| 51 | + List<dynamic> toJson() { |
| 52 | + final result = <Reaction>[]; |
| 53 | + for (final reactionWithVotes in aggregated) { |
| 54 | + result.addAll(reactionWithVotes.userIds.map((userId) => Reaction( |
| 55 | + reactionType: reactionWithVotes.reactionType, |
| 56 | + emojiCode: reactionWithVotes.emojiCode, |
| 57 | + emojiName: reactionWithVotes.emojiName, |
| 58 | + userId: userId, |
| 59 | + ))); |
| 60 | + } |
| 61 | + return result; |
| 62 | + } |
| 63 | + |
| 64 | + void add(Reaction reaction) { |
| 65 | + final currentIndex = aggregated.indexWhere((r) { |
| 66 | + return r.reactionType == reaction.reactionType && r.emojiCode == reaction.emojiCode; |
| 67 | + }); |
| 68 | + if (currentIndex == -1) { |
| 69 | + final newItem = ReactionWithVotes.empty(reaction); |
| 70 | + newItem.userIds.add(reaction.userId); |
| 71 | + aggregated.add(newItem); |
| 72 | + } else { |
| 73 | + final current = aggregated[currentIndex]; |
| 74 | + current.userIds.add(reaction.userId); |
| 75 | + |
| 76 | + // Reposition `current` in list to keep it sorted by number of votes |
| 77 | + final newIndex = 1 + aggregated.lastIndexWhere( |
| 78 | + (item) => item.userIds.length >= current.userIds.length, |
| 79 | + currentIndex - 1, |
| 80 | + ); |
| 81 | + if (newIndex < currentIndex) { |
| 82 | + aggregated |
| 83 | + ..setRange(newIndex + 1, currentIndex + 1, aggregated, newIndex) |
| 84 | + ..[newIndex] = current; |
| 85 | + } |
| 86 | + } |
| 87 | + _total++; |
| 88 | + } |
| 89 | + |
| 90 | + void remove({ |
| 91 | + required ReactionType reactionType, |
| 92 | + required String emojiCode, |
| 93 | + required int userId, |
| 94 | + }) { |
| 95 | + final currentIndex = aggregated.indexWhere((r) { |
| 96 | + return r.reactionType == reactionType && r.emojiCode == emojiCode; |
| 97 | + }); |
| 98 | + if (currentIndex == -1) { // TODO(log) |
| 99 | + return; |
| 100 | + } |
| 101 | + final current = aggregated[currentIndex]; |
| 102 | + current.userIds.remove(userId); |
| 103 | + if (current.userIds.isEmpty) { |
| 104 | + aggregated.removeAt(currentIndex); |
| 105 | + } else { |
| 106 | + final lteIndex = aggregated.indexWhere( |
| 107 | + (item) => item.userIds.length <= current.userIds.length, |
| 108 | + currentIndex + 1, |
| 109 | + ); |
| 110 | + final newIndex = lteIndex == -1 ? aggregated.length - 1 : lteIndex - 1; |
| 111 | + if (newIndex > currentIndex) { |
| 112 | + aggregated |
| 113 | + ..setRange(currentIndex, newIndex, aggregated, currentIndex + 1) |
| 114 | + ..[newIndex] = current; |
| 115 | + } |
| 116 | + } |
| 117 | + _total--; |
| 118 | + } |
| 119 | +} |
| 120 | + |
| 121 | +/// A data structure identifying a reaction and who has voted for it. |
| 122 | +/// |
| 123 | +/// [emojiName] is not part of the key identifying the reaction. |
| 124 | +/// Servers don't key on it (only user, message, reaction type, and emoji code), |
| 125 | +/// and we mimic that behavior: |
| 126 | +/// https://github.com/zulip/zulip-flutter/pull/256#discussion_r1284865099 |
| 127 | +/// It's included here so we can display it in UI. |
| 128 | +class ReactionWithVotes { |
| 129 | + final ReactionType reactionType; |
| 130 | + final String emojiCode; |
| 131 | + final String emojiName; |
| 132 | + final Set<int> userIds = {}; |
| 133 | + |
| 134 | + ReactionWithVotes.empty(Reaction reaction) |
| 135 | + : reactionType = reaction.reactionType, |
| 136 | + emojiCode = reaction.emojiCode, |
| 137 | + emojiName = reaction.emojiName; |
| 138 | + |
| 139 | + @override |
| 140 | + String toString() => 'ReactionWithVotes(reactionType: $reactionType, emojiCode: $emojiCode, emojiName: $emojiName, userIds: $userIds)'; |
| 141 | +} |
| 142 | + |
| 143 | +/// A reaction object found inside message objects in the Zulip API. |
| 144 | +/// |
| 145 | +/// E.g., under "reactions:" in <https://zulip.com/api/get-message>. |
6 | 146 | @JsonSerializable(fieldRename: FieldRename.snake)
|
7 | 147 | class Reaction {
|
8 | 148 | final String emojiName;
|
|
0 commit comments