Skip to content

Commit f2a1b1f

Browse files
committed
wip model: Add Reactions data structure, for efficient access in UI
TODO more tests (see TODOs in model_test.dart)
1 parent 75b81a6 commit f2a1b1f

File tree

7 files changed

+280
-22
lines changed

7 files changed

+280
-22
lines changed

lib/api/model/model.dart

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'package:collection/collection.dart';
2+
import 'package:flutter/foundation.dart';
13
import 'package:json_annotation/json_annotation.dart';
24

35
part 'model.g.dart';
@@ -258,7 +260,7 @@ sealed class Message {
258260
bool isMeMessage;
259261
int? lastEditTimestamp;
260262

261-
final List<Reaction> reactions;
263+
final Reactions reactions;
262264
final int recipientId;
263265
final String senderEmail;
264266
final String senderFullName;
@@ -305,6 +307,121 @@ sealed class Message {
305307
Map<String, dynamic> toJson();
306308
}
307309

310+
/// A message's reactions, in a convenient data structure.
311+
class Reactions {
312+
final List<Reaction> unaggregated;
313+
314+
/// A list of [ReactionWithVotes] objects.
315+
///
316+
/// There won't be two items with the same
317+
/// [ReactionWithVotes.reactionType] and [ReactionWithVotes.emojiCode].
318+
/// (We don't also key on [ReactionWithVotes.emojiName];
319+
/// see [ReactionWithVotes].)
320+
late final List<ReactionWithVotes> aggregated;
321+
322+
Reactions(this.unaggregated) {
323+
final byReaction = <int, ReactionWithVotes>{};
324+
for (final reaction in unaggregated) {
325+
final key = Object.hash(reaction.reactionType, reaction.emojiCode);
326+
final current = byReaction[key] ??= ReactionWithVotes(
327+
reactionType: reaction.reactionType,
328+
emojiCode: reaction.emojiCode,
329+
emojiName: reaction.emojiName,
330+
);
331+
current.addVote(reaction.userId);
332+
}
333+
334+
aggregated = byReaction.values.sorted(
335+
// Descending by number of votes
336+
(a, b) => -a._userIds.length.compareTo(b._userIds.length),
337+
);
338+
}
339+
340+
void add(Reaction reaction) {
341+
unaggregated.add(reaction);
342+
final current = aggregated.where((r) {
343+
return r.reactionType == reaction.reactionType && r.emojiCode == reaction.emojiCode;
344+
}).firstOrNull;
345+
if (current == null) {
346+
final newThing = ReactionWithVotes(
347+
reactionType: reaction.reactionType,
348+
emojiCode: reaction.emojiCode,
349+
emojiName: reaction.emojiName,
350+
);
351+
newThing.addVote(reaction.userId);
352+
aggregated.add(newThing);
353+
} else {
354+
current.addVote(reaction.userId);
355+
}
356+
// TODO could reposition `current` in list to keep it sorted by userIds size
357+
}
358+
359+
void remove({
360+
required ReactionType reactionType,
361+
required String emojiCode,
362+
required int userId,
363+
}) {
364+
unaggregated.removeWhere((r) {
365+
return r.reactionType == reactionType
366+
&& r.emojiCode == emojiCode
367+
&& r.userId == userId;
368+
});
369+
final currentIndex = aggregated.indexWhere((r) {
370+
return r.reactionType == reactionType && r.emojiCode == emojiCode;
371+
});
372+
if (currentIndex == -1) { // TODO(log)
373+
return;
374+
}
375+
final current = aggregated[currentIndex];
376+
current._userIds.remove(userId);
377+
if (current._userIds.isEmpty) {
378+
aggregated.removeAt(currentIndex);
379+
}
380+
// TODO could reposition `current` in list to keep it sorted by userIds size
381+
}
382+
383+
factory Reactions.fromJson(List<dynamic> json) {
384+
return Reactions(
385+
json.map((r) => Reaction.fromJson(r as Map<String, dynamic>)).toList(),
386+
);
387+
}
388+
389+
List<dynamic> toJson() => unaggregated;
390+
}
391+
392+
/// A data structure identifying a reaction and who has voted for it.
393+
///
394+
/// [emojiName] is not part of the key identifying the reaction.
395+
/// Servers don't key on it (only user, message, reaction type, and emoji code),
396+
/// and we mimic that behavior:
397+
/// https://github.com/zulip/zulip-flutter/pull/256#discussion_r1284865099
398+
/// It's included here so we can display it in UI.
399+
class ReactionWithVotes {
400+
final ReactionType reactionType;
401+
final String emojiCode;
402+
final String emojiName;
403+
404+
Set<int> get userIds => _userIds;
405+
final Set<int> _userIds = {};
406+
407+
void addVote(int userId) {
408+
_userIds.add(userId);
409+
}
410+
411+
void removeVote(int userId) {
412+
_userIds.remove(userId);
413+
}
414+
415+
ReactionWithVotes({
416+
required this.reactionType,
417+
required this.emojiCode,
418+
required this.emojiName,
419+
});
420+
421+
@override
422+
String toString() => 'ReactionWithVotes(reactionType: $reactionType, emojiCode: $emojiCode, emojiName: $emojiName, userIds: $userIds)';
423+
}
424+
308425
@JsonSerializable(fieldRename: FieldRename.snake)
309426
class StreamMessage extends Message {
310427
@override

lib/api/model/model.g.dart

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

lib/model/message_list.dart

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -357,11 +357,11 @@ class MessageListView with ChangeNotifier, _MessageSequence {
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+
message.reactions.remove(
361+
reactionType: event.reactionType,
362+
emojiCode: event.emojiCode,
363+
userId: event.userId,
364+
);
365365
}
366366

367367
notifyListeners();

test/api/model/events_test.dart

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

test/api/model/model_checks.dart

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,47 @@ 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<String>> get flags => has((e) => e.flags, 'flags');
1010

1111
// TODO accessors for other fields
1212
}
1313

14-
extension ReactionsChecks on Subject<List<Reaction>> {
14+
15+
extension ReactionsListChecks on Subject<List<Reaction>> {
1516
void deepEquals(_) {
1617
throw UnimplementedError('Tried to call [Subject<List<Reaction>>.deepEquals]. Use jsonEquals instead.');
1718
}
1819
}
1920

21+
extension ReactionsChecks on Subject<Reactions> {
22+
void deepEquals(_) {
23+
throw UnimplementedError('Tried to call [Subject<Reactions>.deepEquals]. Use jsonEquals instead.');
24+
}
25+
26+
Subject<List<Reaction>> get unaggregated => has((e) => e.unaggregated, 'unaggregated');
27+
Subject<List<ReactionWithVotes>> get aggregated => has((e) => e.aggregated, 'aggregated');
28+
}
29+
30+
extension ReactionWithVotesChecks on Subject<ReactionWithVotes> {
31+
Subject<ReactionType> get reactionType => has((r) => r.reactionType, 'reactionType');
32+
Subject<String> get emojiCode => has((r) => r.emojiCode, 'emojiCode');
33+
Subject<String> get emojiName => has((r) => r.emojiName, 'emojiName');
34+
Subject<Set<int>> get userIds => has((e) => e.userIds, 'userIds');
35+
36+
/// Whether this [ReactionWithVotes] corresponds to the given same-emoji [reactions].
37+
void matchesReactions(List<Reaction> reactions) {
38+
assert(reactions.isNotEmpty);
39+
final first = reactions.first;
40+
assert(reactions.every((r) => r.reactionType == first.reactionType && r.emojiCode == first.emojiCode));
41+
return which(it()
42+
..reactionType.equals(first.reactionType)
43+
..emojiCode.equals(first.emojiCode)
44+
..userIds.deepEquals(reactions.map((r) => r.userId).toSet())
45+
);
46+
}
47+
}
48+
2049
extension ReactionChecks on Subject<Reaction> {
2150
Subject<String> get emojiName => has((r) => r.emojiName, 'emojiName');
2251
Subject<String> get emojiCode => has((r) => r.emojiCode, 'emojiCode');

test/api/model/model_test.dart

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import 'package:checks/checks.dart';
2+
import 'package:checks/context.dart';
23
import 'package:test/scaffolding.dart';
34
import 'package:zulip/api/model/model.dart';
45

56
import '../../example_data.dart' as eg;
7+
import '../../stdlib_checks.dart';
8+
import 'model_checks.dart';
69

710
void main() {
811
group('User', () {
@@ -48,8 +51,9 @@ void main() {
4851
});
4952

5053
group('DmMessage', () {
51-
final Map<String, dynamic> baseJson = Map.unmodifiable(
52-
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]).toJson());
54+
final Map<String, dynamic> baseJson = Map.unmodifiable(deepToJson(
55+
eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]),
56+
) as Map<String, dynamic>);
5357

5458
DmMessage parse(Map<String, dynamic> specialJson) {
5559
return DmMessage.fromJson({ ...baseJson, ...specialJson });
@@ -108,4 +112,118 @@ void main() {
108112
.deepEquals([2, 3, 11]);
109113
});
110114
});
115+
116+
group('Reactions', () {
117+
// helper to cut out "it()..isA<ReactionWithVotes>()" goo for callers
118+
ConditionSubject matchesReactions(List<Reaction> reactions) {
119+
return it()..isA<ReactionWithVotes>().matchesReactions(reactions);
120+
}
121+
122+
test('fromJson', () {
123+
final reaction1Json = {'emoji_name': 'thumbs_up', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': 1};
124+
final reaction2Json = {'emoji_name': 'thumbs_up', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': 2};
125+
final reaction3Json = {'emoji_name': 'thumbs_up', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': 3};
126+
127+
final reaction4Json = {'emoji_name': 'twocents', 'emoji_code': '181', 'reaction_type': 'realm_emoji', 'user_id': 1};
128+
final reaction5Json = {'emoji_name': 'twocents', 'emoji_code': '181', 'reaction_type': 'realm_emoji', 'user_id': 2};
129+
130+
final reaction6Json = {'emoji_name': 'zulip', 'emoji_code': 'zulip', 'reaction_type': 'zulip_extra_emoji', 'user_id': 4};
131+
final reaction7Json = {'emoji_name': 'zulip', 'emoji_code': 'zulip', 'reaction_type': 'zulip_extra_emoji', 'user_id': 5};
132+
133+
final reaction1 = Reaction.fromJson(reaction1Json);
134+
final reaction2 = Reaction.fromJson(reaction2Json);
135+
final reaction3 = Reaction.fromJson(reaction3Json);
136+
final reaction4 = Reaction.fromJson(reaction4Json);
137+
final reaction5 = Reaction.fromJson(reaction5Json);
138+
final reaction6 = Reaction.fromJson(reaction6Json);
139+
final reaction7 = Reaction.fromJson(reaction7Json);
140+
141+
check(Reactions.fromJson([
142+
reaction1Json, reaction2Json, reaction3Json, reaction4Json, reaction5Json, reaction6Json, reaction7Json
143+
]))
144+
..unaggregated.jsonEquals([
145+
reaction1, reaction2, reaction3, reaction4, reaction5, reaction6, reaction7,
146+
])
147+
..aggregated.deepEquals([
148+
matchesReactions([reaction1, reaction2, reaction3]),
149+
matchesReactions([reaction4, reaction5]),
150+
matchesReactions([reaction6, reaction7]),
151+
]);
152+
153+
// TODO test sorting of `aggregated`
154+
});
155+
156+
test('add', () {
157+
final reaction0 = Reaction(
158+
reactionType: ReactionType.unicodeEmoji, emojiCode: '1f44d', emojiName: 'thumbs_up', userId: 1);
159+
final reactions = Reactions([reaction0]);
160+
check(reactions)
161+
..unaggregated.jsonEquals([reaction0])
162+
..aggregated.deepEquals([
163+
matchesReactions([reaction0])
164+
]);
165+
166+
// …Different reactionType
167+
final reaction1 = Reaction(
168+
reactionType: ReactionType.realmEmoji, emojiCode: '181', emojiName: 'twocents', userId: 1);
169+
reactions.add(reaction1);
170+
check(reactions)
171+
..unaggregated.jsonEquals([reaction0, reaction1])
172+
..aggregated.deepEquals([
173+
matchesReactions([reaction0]),
174+
matchesReactions([reaction1]),
175+
]);
176+
177+
// …Same reactionType, different emojiCode
178+
final reaction2 = Reaction(
179+
reactionType: ReactionType.realmEmoji, emojiCode: '113', emojiName: 'slackzulip', userId: 1);
180+
reactions.add(reaction2);
181+
check(reactions)
182+
..unaggregated.jsonEquals([reaction0, reaction1, reaction2])
183+
..aggregated.deepEquals([
184+
matchesReactions([reaction0]),
185+
matchesReactions([reaction1]),
186+
matchesReactions([reaction2]),
187+
]);
188+
189+
// …Same reaction, different user
190+
final reaction3 = Reaction(
191+
reactionType: ReactionType.realmEmoji, emojiCode: '113', emojiName: 'slackzulip', userId: 2);
192+
reactions.add(reaction3);
193+
check(reactions)
194+
..unaggregated.jsonEquals([reaction0, reaction1, reaction2, reaction3])
195+
..aggregated.deepEquals([
196+
matchesReactions([reaction0]),
197+
matchesReactions([reaction1]),
198+
matchesReactions([reaction2, reaction3]),
199+
]);
200+
201+
final reaction4 = Reaction(
202+
reactionType: ReactionType.unicodeEmoji, emojiCode: '1f6e0', emojiName: 'working_on_it', userId: 2);
203+
reactions.add(reaction4);
204+
check(reactions)
205+
..unaggregated.jsonEquals([reaction0, reaction1, reaction2, reaction3, reaction4])
206+
..aggregated.deepEquals([
207+
matchesReactions([reaction0]),
208+
matchesReactions([reaction1]),
209+
matchesReactions([reaction2, reaction3]),
210+
matchesReactions([reaction4]),
211+
]);
212+
213+
// …Same reactionType and emojiCode, different emojiName
214+
final reaction5 = Reaction(
215+
reactionType: ReactionType.unicodeEmoji, emojiCode: '1f6e0', emojiName: 'tools', userId: 2);
216+
reactions.add(reaction5);
217+
check(reactions)
218+
..unaggregated.jsonEquals([reaction0, reaction1, reaction2, reaction3, reaction4, reaction5])
219+
..aggregated.deepEquals([
220+
matchesReactions([reaction0]),
221+
matchesReactions([reaction1]),
222+
matchesReactions([reaction2, reaction3]),
223+
matchesReactions([reaction4, reaction5]),
224+
]);
225+
});
226+
227+
// TODO test `remove`
228+
});
111229
}

test/model/message_list_test.dart

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -406,10 +406,8 @@ void main() async {
406406
final reaction3 = Reaction(reactionType: ReactionType.unicodeEmoji,
407407
emojiName: 'working_on_it', emojiCode: '1f6e0', userId: 1);
408408

409-
// Same user, same emojiCode, different emojiName. To be removed: servers
410-
// key on user, message, reaction type, and emoji code, but not emoji name.
411-
// So we mimic that behavior; see discussion:
412-
// https://github.com/zulip/zulip-flutter/pull/256#discussion_r1284865099
409+
// Same user, same emojiCode, different emojiName. To be removed:
410+
// we don't key on emoji name (see [ReactionWithVotes]).
413411
final reaction4 = Reaction(reactionType: ReactionType.unicodeEmoji,
414412
emojiName: 'hello', emojiCode: '1f44b', userId: 1);
415413

@@ -424,7 +422,7 @@ void main() async {
424422
checkNotifiedOnce();
425423
check(model).messages.single
426424
..identicalTo(message)
427-
..reactions.jsonEquals([reaction2, reaction3]);
425+
..reactions.jsonEquals(Reactions([reaction2, reaction3]));
428426
});
429427

430428
test('remove reaction; message is not in list', () async {

0 commit comments

Comments
 (0)