-
Notifications
You must be signed in to change notification settings - Fork 309
model: Add Reactions
data structure, for efficient access in UI
#286
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Comments below.
I haven't yet looked at the tests; LMK if that's also an area you'd like feedback on at this stage.
lib/api/model/model.dart
Outdated
@@ -305,6 +307,121 @@ sealed class Message { | |||
Map<String, dynamic> toJson(); | |||
} | |||
|
|||
/// A message's reactions, in a convenient data structure. | |||
class Reactions { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's put this in its own file, now that it's growing this data-structure complexity. Say lib/api/model/reaction.dart
, and then the other reaction-related types like Reaction and ReactionType can go there too.
lib/api/model/model.dart
Outdated
Set<int> get userIds => _userIds; | ||
final Set<int> _userIds = {}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can just be final Set<int> userIds = {};
— because it's final, there's no setting it anyway, so the getter gives just as much access as the field.
lib/api/model/model.dart
Outdated
@@ -305,6 +307,121 @@ sealed class Message { | |||
Map<String, dynamic> toJson(); | |||
} | |||
|
|||
/// A message's reactions, in a convenient data structure. | |||
class Reactions { | |||
final List<Reaction> unaggregated; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I think cleaner to leave this out.
For efficiently counting the total in order to implement UserSettings.displayEmojiReactionUsers
, we can keep a field like int total
, and just increment and decrement that appropriately.
lib/api/model/model.dart
Outdated
/// [ReactionWithVotes.reactionType] and [ReactionWithVotes.emojiCode]. | ||
/// (We don't also key on [ReactionWithVotes.emojiName]; | ||
/// see [ReactionWithVotes].) | ||
late final List<ReactionWithVotes> aggregated; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Instead of late final
, the idiom that I think I've seen more in the Flutter tree is
- have a plain
final
field; - have a constructor that takes that directly as an argument, but is private;
- then have a factory constructor that does the computation akin to your constructor below, and ends by calling the private constructor.
That's basically the pattern we used in RecentDmConversationsView
, too.
lib/api/model/model.dart
Outdated
final key = Object.hash(reaction.reactionType, reaction.emojiCode); | ||
final current = byReaction[key] ??= ReactionWithVotes( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This can give incorrect results, because Object.hash
can have collisions. (That is, it's not a cryptographic hash function.)
Instead, you can use the Reaction objects themselves as the keys, but with a custom equality and hash function. These can be supplied as part of the map object, using the LinkedHashMap
constructor.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Neat!
lib/api/model/model.dart
Outdated
} else { | ||
current.addVote(reaction.userId); | ||
} | ||
// TODO could reposition `current` in list to keep it sorted by userIds size |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, that's probably best. Keeps things more consistent between what we get from processing events, and what we'd get if we re-fetched the message from scratch.
Can do that by walking backward in the list until we find an entry that has at least this many votes, or the beginning of the list; this entry then goes just after that one.
lib/api/model/model.dart
Outdated
Reactions(this.unaggregated) { | ||
final byReaction = <int, ReactionWithVotes>{}; | ||
for (final reaction in unaggregated) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As far as efficiency is concerned, I think probably the most important single thing to do on this model is to minimize the work that's done for a message that has no reactions. That's probably the vast majority of messages.
A basic step to take in that direction would be to just give this constructor a short-cut path, where if the given list of reactions is empty then we skip making a map and just say the aggregated list is empty.
A more complete step would be to make Message.reactions
nullable, with null representing empty, in the same way as User.profileData
. That will make consuming code slightly more complicated; but I think the number of places in the code that will consume a message's reactions is fairly small, so that probably isn't too bad.
lib/api/model/model.dart
Outdated
|
||
void add(Reaction reaction) { | ||
unaggregated.add(reaction); | ||
final current = aggregated.where((r) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This linear-scan approach seems fine. In principle it could be slow, if the message has a long list of distinct reactions; but I think that's very uncommon.
In the constructor, though, good to use a hash table, as you do. That's potentially processing quite a lot of reactions at once (even though usually not that many distinct ones), plus it can be part of processing a whole bunch of messages at once. So the same speed difference there would have a much greater risk of causing a dropped frame.
One important optimization for efficiency is, when a message has no reactions, to initialize its `.reactions` to `null` instead of an empty [Reactions] instance: zulip#286 (comment) In the message-list-model tests, remove the part that inspects how the reactions data structure ends up looking -- that's now covered in dedicated unit tests for the data structure. Instead, just make sure the data structure's `add` and `remove` are called as appropriate.
f2a1b1f
to
22e7d7e
Compare
Thanks for the review! Revision pushed, and I've addressed those self-TODOs in the tests this round. 🙂 |
Reactions
data structure, for efficient access in UIReactions
data structure, for efficient access in UI
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision! Comments below.
I'm also seeing a number of failures in the tests, particularly in the form of MalformedServerResponseException in MessageListView.fetchInitial. So those will call for some debugging.
lib/model/message_list.dart
Outdated
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; | |||
|
|||
import '../api/model/events.dart'; | |||
import '../api/model/model.dart'; | |||
import '../api/model/reaction.dart'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is probably a good use case for an export
directive. In lib/api/model/model.dart
, add:
export 'reaction.dart';
as a new stanza after the imports. Then all these files that just want to talk about the model, with reactions included, don't need a separate import for reaction.dart
.
Effectively that means that the internal organization of the model into files — the fact that model.dart
refers to another file to get the Reactions
type that Message.reactions
uses, rather than define it within the same file — isn't something other code needs to care about.
lib/api/model/reaction.dart
Outdated
/// As in [Message.reactions]. | ||
@JsonSerializable(fieldRename: FieldRename.snake) | ||
class Reaction { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This dartdoc needs updating. :-)
Probably it should become less terse, now that this class's relationships are more complicated. I think the key fact about this class is that it describes the reaction objects found inside message objects in the Zulip API. That's no longer expressed by a reference to [Message.reactions], so probably this should gain a link directly to the API docs as part of that explanation.
lib/api/model/reaction.dart
Outdated
required int userId | ||
})? debugOnRemove; | ||
|
||
factory Reactions.fromJson(List<dynamic> json) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's put all the constructors next to each other; I think that's helpful for finding them. The toJson
method can go right after that, to keep it near fromJson
.
int get total => _total; | ||
int _total; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like this doesn't get updated on add/remove.
Relatedly, it should get checked in the tests. 😉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah indeed.
Should we check total
in the message-list event handling tests too? Or would that be redundant with checking it in the Reactions
model tests?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just in the model tests seems fine — as long as the message-list tests check that the messages end up with the right list of reactions, I'm happy to leave to the Reactions
model tests the verification that total
(as well as the grouping and sorting of reactions in aggregated
) is correctly kept in sync with the list of reactions.
lib/api/model/reaction.dart
Outdated
void addVote(int userId) { | ||
userIds.add(userId); | ||
} | ||
|
||
void removeVote(int userId) { | ||
userIds.remove(userId); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are probably cleanest to just inline.
test/api/model/model_checks.dart
Outdated
return which(it() | ||
..reactionType.equals(first.reactionType) | ||
..emojiCode.equals(first.emojiCode) | ||
..userIds.deepEquals(reactions.map((r) => r.userId).toSet()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: can pull this set out as a local variable, and then reuse in the assert above
test/api/model/reaction_test.dart
Outdated
final reaction1Json = {'emoji_name': 'thumbs_up', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': 1}; | ||
final reaction2Json = {'emoji_name': 'thumbs_up', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': 2}; | ||
final reaction3Json = {'emoji_name': 'thumbs_up', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': 3}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be good to also include reactions with the same type and code but different names. That's a direction in which it'd be easy to regress the logic someday (partly because the desired/specified logic is a bit peculiar).
I see that case is exercised for add
, which is good, and for remove
is made impossible by its API, which is great. But the constructors have their own logic which could independently have a regression, so should also test this case.
matchesReactions([reaction1]), | ||
]); | ||
|
||
// …Same reactionType, different emojiCode |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another fun case which should be tested: different reactionType but the same emojiCode.
For realistic data for that, try grep -P "'\d+'," src/emoji/codePointMap.js
in zulip-mobile. That finds Unicode emoji known to Zulip whose emoji codes look the same as a decimal integer, which means they look the same as some realm emoji.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ha, interesting.
test/model/message_list_test.dart
Outdated
check(model).messages.single | ||
..identicalTo(message) | ||
..reactions.jsonEquals([eg.unicodeEmojiReaction]); | ||
check(model).messages.single.identicalTo(message); | ||
check(reactionsDotAddCalls).single | ||
..reactionType.equals(eventReaction.reactionType) | ||
..emojiCode.equals(eventReaction.emojiCode) | ||
..emojiName.equals(eventReaction.emojiName) | ||
..userId.equals(eventReaction.userId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, this feels like quite a bit more boilerplate in the test code. I'm also a bit wary of putting in these spy hooks and testing on those — I'd rather test the end state, where possible. (Plugins are an area where that's not possible, hence the "binding" indirection for those.)
I think we can actually test the end state pretty reasonably. Here's a simple version:
check(model).messages.single
..identicalTo(message)
..reactions.isNotNull().aggregated.jsonEquals([eg.unicodeEmojiReaction]);
As long as there's only a very few reactions involved, so no complexity from wanting to not care about the order of them etc., that should work pretty well.
If that were to start to get complicated, then the principle I think I'd want to express is "this Reactions object is in an equivalent state to where it'd be if we initialized a Reactions object from the following list of reactions". That keeps it pretty well isolated from the internal implementation details of Reactions, and I think in any given test case it should be straightforward to come up with what that list of reactions should be. Then it'd be a matter of implementing that, loosely along the lines of your matchesReactions
condition on ReactionWithVotes
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean
..reactions.isNotNull().jsonEquals([eg.unicodeEmojiReaction]);
in place of
..reactions.isNotNull().aggregated.jsonEquals([eg.unicodeEmojiReaction]);
? If checking aggregated
, we probably also want to check its userIds
, right? And if we want to check aggregated
with jsonEquals
, we'll need to give ReactionWithVotes
a toJson
method (one that will probably never run in app code).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm yeah, I think that probably is what I had in mind — get the disaggregated list of reactions and check that that's what we expect.
test/model/message_list_test.dart
Outdated
final someMessage = eg.streamMessage(id: 1, reactions: [eg.unicodeEmojiReaction]); | ||
final someMessage = eg.streamMessage(id: 1, reactions: Reactions([eg.unicodeEmojiReaction])); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems like it's more convenient for callers of eg.streamMessage
if this reactions
parameter remains just a (nullable) list. The implementation can then say something like
'reactions': reactions == null ? null : Reactions(reactions),
One important optimization for efficiency is, when a message has no reactions, to initialize its `.reactions` to `null` instead of an empty [Reactions] instance: zulip#286 (comment) In the message-list-model tests, remove the part that inspects how the reactions data structure ends up looking -- that's now covered in dedicated unit tests for the data structure. Instead, just make sure the data structure's `add` and `remove` are called as appropriate.
22e7d7e
to
ca49ed4
Compare
Thanks for the review! Revision pushed, and see one small query here: #286 (comment) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision! Generally all now looks good; just a handful of comments.
lib/api/model/reaction.dart
Outdated
aggregated.setRange(newIndex, currentIndex + 1, | ||
[current].followedBy(aggregated.sublist(newIndex, currentIndex))); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This version may currently work, but I think the List.setRange
docs disclaim any promise that it works:
If iterable is this list, the operation correctly copies the elements originally in the range from skipCount to skipCount + (end - start) to the range start to end, even if the two ranges overlap.
If iterable depends on this list in some other way, no guarantees are made.
Here the iterable
argument depends on the receiver list (namely aggregated
), in a way other than directly being that list.
So instead the setRange
call will need to pass aggregated
itself as the iterable; it can use the skipCount
parameter to control what part of the list is used. Then moving current
can be a plain []=
call.
@@ -381,7 +381,7 @@ void main() async { | |||
checkNotifiedOnce(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: an early commit adds an import to this file that's redundant in this revision, which a later commit removes
..reactions.jsonEquals([eg.unicodeEmojiReaction]); | ||
..reactions.isNotNull().jsonEquals([eg.unicodeEmojiReaction]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: the main final commit's message says:
In the message-list-model tests, remove the part that inspects how
the reactions data structure ends up looking -- that's now covered
in dedicated unit tests for the data structure. Instead, just make
sure the data structure's `add` and `remove` are called as
appropriate.
which is something that no longer happens in this revision
static Object _reactionsToJson(Reactions? value) { | ||
return value ?? []; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah I see, so this is what fixed those test failures (#286 (review)).
One important optimization for efficiency is, when a message has no reactions, to initialize its `.reactions` to `null` instead of an empty [Reactions] instance: zulip#286 (comment)
ca49ed4
to
2bb3f1e
Compare
Thanks for the review! Revision pushed. |
Thanks! All now looks good, but there's conflicts with your #298 which I just merged. Would you rebase past those? |
One important optimization for efficiency is, when a message has no reactions, to initialize its `.reactions` to `null` instead of an empty [Reactions] instance: zulip#286 (comment)
2bb3f1e
to
7b0bdb6
Compare
Sure! Thanks for the review; just rebased past those conflicts and pushed the result here. |
We'll add more things here soon.
One important optimization for efficiency is, when a message has no reactions, to initialize its `.reactions` to `null` instead of an empty [Reactions] instance: zulip#286 (comment)
7b0bdb6
to
744c1f8
Compare
Thanks! Merging. |
I thought I'd check in with my progress toward keeping an efficient data structure to support the list of reaction buttons on a message in the message list. 🙂
As discussed in chat, this goes directly on
Message
instances.In this revision, the data structure retains a raw, "unaggregated" list, like the list it gets from the server. That doesn't have to stay, since it takes up space, but it does make it efficient to count reactions for
UserSettings.displayEmojiReactionUsers
, and it supports a nice and efficientReactions.toJson
in case the app starts requiring that.There are TODOs in the tests, but I'll get to those. 🙂
Related: #121