Skip to content

Commit 7b0bdb6

Browse files
committed
model: Add Reactions data structure, for efficient access in UI
One important optimization for efficiency is, when a message has no reactions, to initialize its `.reactions` to `null` instead of an empty [Reactions] instance: #286 (comment)
1 parent 0eec6fb commit 7b0bdb6

10 files changed

+415
-32
lines changed

lib/api/model/model.dart

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,9 @@ sealed class Message {
262262
bool isMeMessage;
263263
int? lastEditTimestamp;
264264

265-
final List<Reaction> reactions;
265+
@JsonKey(fromJson: _reactionsFromJson, toJson: _reactionsToJson)
266+
Reactions? reactions; // null is equivalent to an empty [Reactions]
267+
266268
final int recipientId;
267269
final String senderEmail;
268270
final String senderFullName;
@@ -280,6 +282,15 @@ sealed class Message {
280282
final String? matchContent;
281283
final String? matchSubject;
282284

285+
static Reactions? _reactionsFromJson(dynamic json) {
286+
final list = (json as List<dynamic>);
287+
return list.isNotEmpty ? Reactions.fromJson(list) : null;
288+
}
289+
290+
static Object _reactionsToJson(Reactions? value) {
291+
return value ?? [];
292+
}
293+
283294
static List<MessageFlag> _flagsFromJson(dynamic json) {
284295
final list = json as List<dynamic>;
285296
return list.map((raw) => MessageFlag.fromRawString(raw as String)).toList();

lib/api/model/model.g.dart

Lines changed: 4 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/api/model/reaction.dart

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,148 @@
1+
import 'dart:collection';
2+
3+
import 'package:collection/collection.dart';
14
import 'package:json_annotation/json_annotation.dart';
25

36
part 'reaction.g.dart';
47

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>.
6146
@JsonSerializable(fieldRename: FieldRename.snake)
7147
class Reaction {
8148
final String emojiName;

lib/model/message_list.dart

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -350,18 +350,21 @@ class MessageListView with ChangeNotifier, _MessageSequence {
350350
final message = messages[index];
351351
switch (event.op) {
352352
case ReactionOp.add:
353-
message.reactions.add(Reaction(
353+
(message.reactions ??= Reactions([])).add(Reaction(
354354
emojiName: event.emojiName,
355355
emojiCode: event.emojiCode,
356356
reactionType: event.reactionType,
357357
userId: event.userId,
358358
));
359359
case ReactionOp.remove:
360-
message.reactions.removeWhere((r) {
361-
return r.emojiCode == event.emojiCode
362-
&& r.reactionType == event.reactionType
363-
&& r.userId == event.userId;
364-
});
360+
if (message.reactions == null) { // TODO(log)
361+
return;
362+
}
363+
message.reactions!.remove(
364+
reactionType: event.reactionType,
365+
emojiCode: event.emojiCode,
366+
userId: event.userId,
367+
);
365368
}
366369

367370
notifyListeners();

test/api/model/events_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ void main() {
1515
MessageEvent mkEvent(List<MessageFlag> flags) => Event.fromJson({
1616
'type': 'message',
1717
'id': 1,
18-
'message': message.toJson()..remove('flags'),
18+
'message': (deepToJson(message) as Map<String, dynamic>)..remove('flags'),
1919
'flags': flags.map((f) => f.toJson()).toList(),
2020
}) as MessageEvent;
2121
check(mkEvent(message.flags)).message.jsonEquals(message);

test/api/model/model_checks.dart

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,41 @@ extension MessageChecks on Subject<Message> {
55
Subject<String> get content => has((e) => e.content, 'content');
66
Subject<bool> get isMeMessage => has((e) => e.isMeMessage, 'isMeMessage');
77
Subject<int?> get lastEditTimestamp => has((e) => e.lastEditTimestamp, 'lastEditTimestamp');
8-
Subject<List<Reaction>> get reactions => has((e) => e.reactions, 'reactions');
8+
Subject<Reactions?> get reactions => has((e) => e.reactions, 'reactions');
99
Subject<List<MessageFlag>> get flags => has((e) => e.flags, 'flags');
1010

1111
// TODO accessors for other fields
1212
}
1313

14-
extension ReactionsChecks on Subject<List<Reaction>> {
15-
void deepEquals(_) {
16-
throw UnimplementedError('Tried to call [Subject<List<Reaction>>.deepEquals]. Use jsonEquals instead.');
14+
extension ReactionsChecks on Subject<Reactions> {
15+
Subject<int> get total => has((e) => e.total, 'total');
16+
Subject<List<ReactionWithVotes>> get aggregated => has((e) => e.aggregated, 'aggregated');
17+
}
18+
19+
extension ReactionWithVotesChecks on Subject<ReactionWithVotes> {
20+
Subject<ReactionType> get reactionType => has((r) => r.reactionType, 'reactionType');
21+
Subject<String> get emojiCode => has((r) => r.emojiCode, 'emojiCode');
22+
Subject<String> get emojiName => has((r) => r.emojiName, 'emojiName');
23+
Subject<Set<int>> get userIds => has((r) => r.userIds, 'userIds');
24+
25+
/// Whether this [ReactionWithVotes] corresponds to the given same-emoji [reactions].
26+
void matchesReactions(List<Reaction> reactions) {
27+
assert(reactions.isNotEmpty);
28+
final first = reactions.first;
29+
30+
// Same emoji for all reactions
31+
assert(reactions.every((r) => r.reactionType == first.reactionType && r.emojiCode == first.emojiCode));
32+
33+
final userIds = Set.from(reactions.map((r) => r.userId));
34+
35+
// No double-votes from one person (we don't expect this from servers)
36+
assert(userIds.length == reactions.length);
37+
38+
return which(it()
39+
..reactionType.equals(first.reactionType)
40+
..emojiCode.equals(first.emojiCode)
41+
..userIds.deepEquals(userIds)
42+
);
1743
}
1844
}
1945

test/api/model/model_test.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,9 @@ void main() {
6666
});
6767

6868
group('DmMessage', () {
69-
final Map<String, dynamic> baseJson = Map.unmodifiable(
70-
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]).toJson());
69+
final Map<String, dynamic> baseJson = Map.unmodifiable(deepToJson(
70+
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]),
71+
) as Map<String, dynamic>);
7172

7273
DmMessage parse(Map<String, dynamic> specialJson) {
7374
return DmMessage.fromJson({ ...baseJson, ...specialJson });

0 commit comments

Comments
 (0)