-
Notifications
You must be signed in to change notification settings - Fork 222
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
Add Reaction
class; uncomment Message.reactions
and update on events
#256
Conversation
lib/api/model/events.dart
Outdated
/// A Zulip event of type `reaction`. | ||
abstract class ReactionEvent extends Event { | ||
@override | ||
@JsonKey(includeToJson: true) | ||
String get type => 'reaction'; | ||
|
||
String get op; | ||
|
||
ReactionEvent({required super.id}); | ||
} |
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.
All of ReactionAddEvent
's and ReactionRemoveEvent
's fields are the same except op
… do you think it would be better to deduplicate by putting the fields on this ReactionEvent
base class?
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 so. I think the agreement in list of fields is not really a coincidence: this reactions data model is designed so that all these fields give the coordinates of one single hypothetical reaction, and then the only things that can happen are that that hypothetical reaction is created or deleted.
Potentially even go further: just have ReactionEvent
, make it concrete, and give it final ReactionOp op;
where that's an enum with values add/remove.
Relatedly, in the consuming code, I think we don't care whether it's an add or a remove until relatively late in handling it. For example MessageListView
can sensibly have just one method that takes a ReactionEvent
and does the appropriate thing (which means PerAccountStore.handleEvent
doesn't care about the details), and then that method can go use messageId
to find the relevant message (and potentially determine it doesn't have it and there's nothing to do) and only after finding the message does it consult op
.
828324b
to
80192ae
Compare
Updated, resolving small conflicts with #238. |
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 think it'll likely make most sense to merge this as part of a PR that's also handling the events. That way we see the whole chain of how they're used, which is helpful for that question about how far to unify the two variants of the event type.
lib/api/model/model.dart
Outdated
final String emojiCode; | ||
final ReactionType reactionType; | ||
final int userId; | ||
// final Map<String, dynamic> user // deprecated; ignore |
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:
// final Map<String, dynamic> user // deprecated; ignore | |
// final Map<String, dynamic> user; // deprecated; ignore |
@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.
@JsonSerializable(fieldRename: FieldRename.snake) | |
class Reaction { | |
/// As in [Message.reactions]. | |
@JsonSerializable(fieldRename: FieldRename.snake) | |
class Reaction { |
This way there's a breadcrumb trail to find the relevant API docs.
lib/api/model/events.dart
Outdated
/// A Zulip event of type `reaction`. | ||
abstract class ReactionEvent extends Event { |
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.
Breadcrumbs to API docs:
/// A Zulip event of type `reaction`. | |
abstract class ReactionEvent extends Event { | |
/// A Zulip event of type `reaction`. | |
/// | |
/// The corresponding API docs are in several places for | |
/// different values of `op`; see subclasses. | |
abstract class ReactionEvent extends Event { |
(Copying language I added just today in StreamEvent and RealmUserEvent.)
lib/api/model/events.dart
Outdated
/// A Zulip event of type `reaction`. | ||
abstract class ReactionEvent extends Event { | ||
@override | ||
@JsonKey(includeToJson: true) | ||
String get type => 'reaction'; | ||
|
||
String get op; | ||
|
||
ReactionEvent({required super.id}); | ||
} |
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 so. I think the agreement in list of fields is not really a coincidence: this reactions data model is designed so that all these fields give the coordinates of one single hypothetical reaction, and then the only things that can happen are that that hypothetical reaction is created or deleted.
Potentially even go further: just have ReactionEvent
, make it concrete, and give it final ReactionOp op;
where that's an enum with values add/remove.
Relatedly, in the consuming code, I think we don't care whether it's an add or a remove until relatively late in handling it. For example MessageListView
can sensibly have just one method that takes a ReactionEvent
and does the appropriate thing (which means PerAccountStore.handleEvent
doesn't care about the details), and then that method can go use messageId
to find the relevant message (and potentially determine it doesn't have it and there's nothing to do) and only after finding the message does it consult op
.
Reaction
class; uncomment Message.reactions
; represent reaction eventsReaction
class; uncomment Message.reactions
and update on events
80192ae
to
272c366
Compare
Thanks for the review! Revision pushed. This one updates the If you spot ways to make the tests more concise, please let me know. 🙂 |
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! A couple of comments below.
On how to make the tests more concise, one thought is below. Then there's a variety of small things most of which apply to the existing tests in that file. For those, I think I'll just go try to work up now a small PR, which may be the most efficient way to describe them.
lib/model/message_list.dart
Outdated
case ReactionOp.add: | ||
message.reactions.add(reaction); | ||
case ReactionOp.remove: | ||
message.reactions.removeWhere((r) => r == 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.
Hmm interesting. This differs from what zulip-mobile does (in messagesReducer.js
):
reactions: oldMessage.reactions.filter(
x => !(x.emoji_name === action.emoji_name && x.user_id === action.user_id),
),
which is to match only on (message and) user ID and emoji name.
The API docs at https://zulip.com/api/get-events#reaction-remove aren't real clear on how to interpret this aspect.
In the server database, the unique key is user, message, reaction type, and emoji code:
class AbstractReaction(AbstractEmoji):
class Meta:
abstract = True
unique_together = ("user_profile", "message", "reaction_type", "emoji_code")
(Seems like that should really be message first, then user — without an index that begins with the message, a query that asks for all the reactions on a given message or set of messages is going to be slow. But the order of columns in the key only affects performance, not semantics.)
That suggests that that's what we should key on as well, in order to correctly simulate what we'd expect to see if we fetched the same message from scratch.
And indeed that looks to be what web does too.
So yeah, let's match on user ID, reaction type, and emoji code here. If there's a match on those but the name was different, we should remove it.
test/model/message_list_test.dart
Outdated
final event = ReactionEvent( | ||
id: 1, | ||
op: ReactionOp.add, | ||
emojiName: eg.unicodeEmojiReaction.emojiName, | ||
emojiCode: eg.unicodeEmojiReaction.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.
One way to make the tests more concise would be to make a local helper function to create these ReactionEvent objects, which could take a Reaction as a single argument. (Plus can skip the id
parameter.)
Sent #258. |
272c366
to
1da3c2f
Compare
Thanks for the review! Revision pushed. |
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! Just small comments remaining, all on the tests.
test/api/model/model_checks.dart
Outdated
@@ -5,15 +5,43 @@ extension MessageChecks on Subject<Message> { | |||
Subject<Map<String, dynamic>> get toJson => has((e) => e.toJson(), 'toJson'); | |||
|
|||
void jsonEquals(Message expected) { | |||
toJson.deepEquals(expected.toJson()); | |||
final expected_ = expected.toJson(); |
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.
How about expectedJson
— that way the name expresses how it differs from the other "expected".
test/api/model/model_checks.dart
Outdated
@@ -5,15 +5,43 @@ extension MessageChecks on Subject<Message> { | |||
Subject<Map<String, dynamic>> get toJson => has((e) => e.toJson(), 'toJson'); | |||
|
|||
void jsonEquals(Message expected) { | |||
toJson.deepEquals(expected.toJson()); | |||
final expected_ = expected.toJson(); | |||
expected_['reactions'] = it()..isA<List<Reaction>>().jsonEquals(expected.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.
Hmm, interesting.
This feels a bit more cumbersome to me than the toDeepJson
(deepToJson
?) approach we discussed in the office on Friday, where in test code we'd have an extension like Message.deepToJson
that had the effect of a recursive toJson
. But this approach seems fine.
(I might experiment afterward with the deepToJson
approach, either ad hoc for Message
or the generic version that we sketched.)
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.
Sure, that approach makes sense; please do go ahead with those experiments if you'd like. 🙂 I may have forgotten some points we discussed on Friday, but I got stuck thinking about how to make it nice to compare two List<Reaction>
s in a test, when you don't necessarily want to compare two whole Message
objects. And the new ReactionsChecks on Subject<List<Reaction>>
in this revision seemed convenient for that, and also as a helper for the Message.jsonEquals
function you've highlighted here.
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.
Sent #259.
test/model/message_list_test.dart
Outdated
final messageList = await messageListViewWithMessages([originalMessage], stream, narrow); | ||
|
||
final message = messageList.messages.single; | ||
check(message).reactions.not(it()..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.
I think this more direct version would be fine here:
check(message).reactions.not(it()..jsonEquals([eg.unicodeEmojiReaction])); | |
check(message).reactions.jsonEquals([]); |
The reader can compare that with the jsonEquals
below and see that the expected values are clearly different — one's empty and one's a singleton.
test/model/message_list_test.dart
Outdated
} | ||
|
||
test('add reaction', () async { | ||
final originalMessage = eg.streamMessage(id: 243, stream: stream, 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.
Can we drop id
here? I think we can.
(Same is probably true of some of the test cases above; I guess that's a cleanup I missed in #258.)
test/model/message_list_test.dart
Outdated
emojiName: 'wave', emojiCode: '1f44b', userId: 1); | ||
|
||
// Same emoji, different user. Not to be removed. | ||
final reaction2 = Reaction.fromJson(eventReaction.toJson()..update('user_id', (_) => 2)); |
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.
final reaction2 = Reaction.fromJson(eventReaction.toJson()..update('user_id', (_) => 2)); | |
final reaction2 = Reaction.fromJson(eventReaction.toJson() | |
..update('user_id', (_) => 2)); |
(to make the parallelism clear with the others below)
test/model/message_list_test.dart
Outdated
final originalMessage = eg.streamMessage(id: 243, stream: stream, | ||
reactions: [eventReaction, reaction2, reaction3, reaction4]); |
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.
To make this consistent with non-buggy server behavior, this should leave out eventReaction
— that can't coexist with reaction4
. (Because they have the same message, user, reaction type, and emoji code, and in the database those form a unique key.)
test/model/message_list_test.dart
Outdated
final messageList = await messageListViewWithMessages([originalMessage], stream, narrow); | ||
|
||
final message = messageList.messages.single; | ||
check(message).reactions.not(it()..jsonEquals([reaction2, reaction3])); |
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, this test is definitely clearer by stating what it expects the starting point to be, rather than that it's different from the expected ending point. :-)
I think in fact this is clear enough if you just leave this check out — the definition of originalMessage
is a few lines earlier and seems equally explicit.
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.
I think in fact this is clear enough if you just leave this check out
Could the same be said for the other added tests, I wonder:
test('add reaction', () async {
final originalMessage = eg.streamMessage(stream: stream, reactions: []);
final messageList = await messageListViewWithMessages([originalMessage], stream, narrow);
final message = messageList.messages.single;
check(message).reactions.jsonEquals([]);
test('add reaction; message is not in list', () async {
final someMessage = eg.streamMessage(id: 1, reactions: []);
final messageList = await messageListViewWithMessages([someMessage], stream, narrow);
check(messageList.messages.single).reactions.jsonEquals([]);
test('remove reaction; message is not in list', () async {
final someMessage = eg.streamMessage(id: 1, reactions: [eg.unicodeEmojiReaction]);
final messageList = await messageListViewWithMessages([someMessage], stream, narrow);
check(messageList.messages.single).reactions.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.
Yeah, I think so.
test/model/message_list_test.dart
Outdated
final reaction4 = Reaction.fromJson(eventReaction.toJson() | ||
..update('emoji_name', (_) => 'tools')); |
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.
I think this aspect doesn't affect the substance of the test, but for realism (which can be helpful for a reader): tools
should correspond to the same emoji code as working_on_it
, rather than the same as wave
.
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.
We want the emojiCode
to match the one in the event, but we want emojiName
not to match, right. Since this is the reaction in the event—
final eventReaction = Reaction(reactionType: ReactionType.unicodeEmoji,
emojiName: 'wave', emojiCode: '1f44b', userId: 1);
—how about this for reaction4
:
final reaction4 = Reaction.fromJson(eventReaction.toJson()
..update('emoji_name', (_) => 'hello'));
? 🙂
1da3c2f
to
e119b93
Compare
Thanks for the review! Revision pushed, and I made some replies inline above. |
test/model/message_list_test.dart
Outdated
// So we mimic that behavior; see discussion: | ||
// https://github.com/zulip/zulip-flutter/pull/256#discussion_r1284865099 | ||
final reaction4 = Reaction.fromJson(eventReaction.toJson() | ||
..update('emoji_name', (_) => 'hello')); |
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 line (and similar ones above) can be simplified with a convenient variant of the ..
cascade syntax:
..update('emoji_name', (_) => 'hello')); | |
..['emoji_name'] = 'hello'); |
That is, the []=
operator can be used in cascade form too, much like a setter can be.
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.
Interesting, thanks! I thought I tried that, but I guess I didn't try it properly. 🤷♂️
test/model/message_list_test.dart
Outdated
final messageList = await messageListViewWithMessages([originalMessage], stream, narrow); | ||
|
||
final message = messageList.messages.single; | ||
check(message).reactions.not(it()..jsonEquals([reaction2, reaction3])); |
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 so.
Thanks! All looks good; small nits above. Please go ahead and merge whenever ready. |
e119b93
to
15099f4
Compare
Thanks for the review!
Merging, with those changes. |
Related: #121