Skip to content

Commit 1da3c2f

Browse files
committed
api: Add reaction events
We don't yet have UI to show the events (#121), but now at least we're keeping our Message objects up-to-date with reactions. Related: #121
1 parent 2fd0c4c commit 1da3c2f

File tree

8 files changed

+253
-2
lines changed

8 files changed

+253
-2
lines changed

lib/api/model/events.dart

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ sealed class Event {
3333
case 'message': return MessageEvent.fromJson(json);
3434
case 'update_message': return UpdateMessageEvent.fromJson(json);
3535
case 'delete_message': return DeleteMessageEvent.fromJson(json);
36+
case 'reaction': return ReactionEvent.fromJson(json);
3637
case 'heartbeat': return HeartbeatEvent.fromJson(json);
3738
// TODO add many more event types
3839
default: return UnexpectedEvent.fromJson(json);
@@ -370,6 +371,50 @@ enum MessageType {
370371
private;
371372
}
372373

374+
/// A Zulip event of type `reaction`, with op `add` or `remove`.
375+
///
376+
/// See:
377+
/// https://zulip.com/api/get-events#reaction-add
378+
/// https://zulip.com/api/get-events#reaction-remove
379+
@JsonSerializable(fieldRename: FieldRename.snake)
380+
class ReactionEvent extends Event {
381+
@override
382+
@JsonKey(includeToJson: true)
383+
String get type => 'reaction';
384+
385+
final ReactionOp op;
386+
387+
final String emojiName;
388+
final String emojiCode;
389+
final ReactionType reactionType;
390+
final int userId;
391+
// final Map<String, dynamic> user; // deprecated; ignore
392+
final int messageId;
393+
394+
ReactionEvent({
395+
required super.id,
396+
required this.op,
397+
required this.emojiName,
398+
required this.emojiCode,
399+
required this.reactionType,
400+
required this.userId,
401+
required this.messageId,
402+
});
403+
404+
factory ReactionEvent.fromJson(Map<String, dynamic> json) =>
405+
_$ReactionEventFromJson(json);
406+
407+
@override
408+
Map<String, dynamic> toJson() => _$ReactionEventToJson(this);
409+
}
410+
411+
/// The type of [ReactionEvent.op].
412+
@JsonEnum(fieldRename: FieldRename.snake)
413+
enum ReactionOp {
414+
add,
415+
remove,
416+
}
417+
373418
/// A Zulip event of type `heartbeat`: https://zulip.com/api/get-events#heartbeat
374419
@JsonSerializable(fieldRename: FieldRename.snake)
375420
class HeartbeatEvent extends Event {

lib/api/model/events.g.dart

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

lib/api/model/model.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,9 @@ class Reaction {
464464
_$ReactionFromJson(json);
465465

466466
Map<String, dynamic> toJson() => _$ReactionToJson(this);
467+
468+
@override
469+
String toString() => 'Reaction(emojiName: $emojiName, emojiCode: $emojiCode, reactionType: $reactionType, userId: $userId)';
467470
}
468471

469472
/// As in [Reaction.reactionType].

lib/model/message_list.dart

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,32 @@ class MessageListView extends ChangeNotifier {
151151
notifyListeners();
152152
}
153153

154+
void maybeUpdateMessageReactions(ReactionEvent event) {
155+
final index = findMessageWithId(event.messageId);
156+
if (index == -1) {
157+
return;
158+
}
159+
160+
final message = messages[index];
161+
switch (event.op) {
162+
case ReactionOp.add:
163+
message.reactions.add(Reaction(
164+
emojiName: event.emojiName,
165+
emojiCode: event.emojiCode,
166+
reactionType: event.reactionType,
167+
userId: event.userId,
168+
));
169+
case ReactionOp.remove:
170+
message.reactions.removeWhere((r) {
171+
return r.emojiCode == event.emojiCode
172+
&& r.reactionType == event.reactionType
173+
&& r.userId == event.userId;
174+
});
175+
}
176+
177+
notifyListeners();
178+
}
179+
154180
/// Called when the app is reassembled during debugging, e.g. for hot reload.
155181
///
156182
/// This will redo from scratch any computations we can, such as parsing

lib/model/store.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,11 @@ class PerAccountStore extends ChangeNotifier {
278278
} else if (event is DeleteMessageEvent) {
279279
assert(debugLog("server event: delete_message ${event.messageIds}"));
280280
// TODO handle
281+
} else if (event is ReactionEvent) {
282+
assert(debugLog("server event: reaction/${event.op}"));
283+
for (final view in _messageListViews) {
284+
view.maybeUpdateMessageReactions(event);
285+
}
281286
} else if (event is UnexpectedEvent) {
282287
assert(debugLog("server event: ${jsonEncode(event.toJson())}")); // TODO log better
283288
} else {

test/api/model/model_checks.dart

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,43 @@ extension MessageChecks on Subject<Message> {
55
Subject<Map<String, dynamic>> get toJson => has((e) => e.toJson(), 'toJson');
66

77
void jsonEquals(Message expected) {
8-
toJson.deepEquals(expected.toJson());
8+
final expected_ = expected.toJson();
9+
expected_['reactions'] = it()..isA<List<Reaction>>().jsonEquals(expected.reactions);
10+
toJson.deepEquals(expected_);
911
}
1012

1113
Subject<String> get content => has((e) => e.content, 'content');
1214
Subject<bool> get isMeMessage => has((e) => e.isMeMessage, 'isMeMessage');
1315
Subject<int?> get lastEditTimestamp => has((e) => e.lastEditTimestamp, 'lastEditTimestamp');
16+
Subject<List<Reaction>> get reactions => has((e) => e.reactions, 'reactions');
1417
Subject<List<String>> get flags => has((e) => e.flags, 'flags');
1518

1619
// TODO accessors for other fields
1720
}
1821

22+
extension ReactionsChecks on Subject<List<Reaction>> {
23+
void deepEquals(_) {
24+
throw UnimplementedError('Tried to call [Subject<List<Reaction>>.deepEquals]. Use jsonEquals instead.');
25+
}
26+
27+
void jsonEquals(List<Reaction> expected) {
28+
// (cast, to bypass this extension's deepEquals implementation, which throws)
29+
// ignore: unnecessary_cast
30+
(this as Subject<List>).deepEquals(expected.map((r) => it()..isA<Reaction>().jsonEquals(r)));
31+
}
32+
}
33+
34+
extension ReactionChecks on Subject<Reaction> {
35+
Subject<Map<String, dynamic>> get toJson => has((r) => r.toJson(), 'toJson');
36+
37+
void jsonEquals(Reaction expected) {
38+
toJson.deepEquals(expected.toJson());
39+
}
40+
41+
Subject<String> get emojiName => has((r) => r.emojiName, 'emojiName');
42+
Subject<String> get emojiCode => has((r) => r.emojiCode, 'emojiCode');
43+
Subject<ReactionType> get reactionType => has((r) => r.reactionType, 'reactionType');
44+
Subject<int> get userId => has((r) => r.userId, 'userId');
45+
}
46+
1947
// TODO similar extensions for other types in model

test/example_data.dart

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ StreamMessage streamMessage({
132132
String? content,
133133
String? contentMarkdown,
134134
int? lastEditTimestamp,
135+
List<Reaction>? reactions,
135136
List<String>? flags,
136137
}) {
137138
final effectiveStream = stream ?? _stream();
@@ -146,7 +147,7 @@ StreamMessage streamMessage({
146147
..._messagePropertiesFromContent(content, contentMarkdown),
147148
'display_recipient': effectiveStream.name,
148149
'stream_id': effectiveStream.streamId,
149-
'reactions': [],
150+
'reactions': reactions?.map((r) => r.toJson()).toList() ?? [],
150151
'flags': flags ?? [],
151152
'id': id ?? 1234567, // TODO generate example IDs
152153
'last_edit_timestamp': lastEditTimestamp,
@@ -187,6 +188,13 @@ DmMessage dmMessage({
187188
});
188189
}
189190

191+
Reaction unicodeEmojiReaction = Reaction(
192+
emojiName: 'thumbs_up',
193+
emojiCode: '1f44d',
194+
reactionType: ReactionType.unicodeEmoji,
195+
userId: selfUser.userId,
196+
);
197+
190198
// TODO example data for many more types
191199

192200
InitialSnapshot initialSnapshot({

test/model/message_list_test.dart

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,5 +162,107 @@ void main() async {
162162
test('rendering-only update does not change timestamp (for old server versions)', () async {
163163
await checkRenderingOnly(legacy: true);
164164
});
165+
166+
group('ReactionEvent handling', () {
167+
ReactionEvent mkEvent(Reaction reaction, ReactionOp op, int messageId) {
168+
return ReactionEvent(
169+
id: 1,
170+
op: op,
171+
emojiName: reaction.emojiName,
172+
emojiCode: reaction.emojiCode,
173+
reactionType: reaction.reactionType,
174+
userId: reaction.userId,
175+
messageId: messageId,
176+
);
177+
}
178+
179+
test('add reaction', () async {
180+
final originalMessage = eg.streamMessage(id: 243, stream: stream, reactions: []);
181+
final messageList = await messageListViewWithMessages([originalMessage], stream, narrow);
182+
183+
final message = messageList.messages.single;
184+
check(message).reactions.not(it()..jsonEquals([eg.unicodeEmojiReaction]));
185+
186+
bool listenersNotified = false;
187+
messageList.addListener(() { listenersNotified = true; });
188+
189+
messageList.maybeUpdateMessageReactions(
190+
mkEvent(eg.unicodeEmojiReaction, ReactionOp.add, originalMessage.id));
191+
192+
check(listenersNotified).isTrue();
193+
check(messageList.messages.single)
194+
..identicalTo(message)
195+
..reactions.jsonEquals([eg.unicodeEmojiReaction]);
196+
});
197+
198+
test('add reaction; message is not in list', () async {
199+
final someMessage = eg.streamMessage(id: 1, reactions: []);
200+
final messageList = await messageListViewWithMessages([someMessage], stream, narrow);
201+
check(messageList.messages.single).reactions.jsonEquals([]);
202+
203+
bool listenersNotified = false;
204+
messageList.addListener(() { listenersNotified = true; });
205+
206+
messageList.maybeUpdateMessageReactions(
207+
mkEvent(eg.unicodeEmojiReaction, ReactionOp.add, 1000));
208+
209+
check(listenersNotified).isFalse();
210+
check(messageList.messages.single).reactions.jsonEquals([]);
211+
});
212+
213+
test('remove reaction', () async {
214+
final eventReaction = Reaction(reactionType: ReactionType.unicodeEmoji,
215+
emojiName: 'wave', emojiCode: '1f44b', userId: 1);
216+
217+
// Same emoji, different user. Not to be removed.
218+
final reaction2 = Reaction.fromJson(eventReaction.toJson()..update('user_id', (_) => 2));
219+
220+
// Same user, different emoji. Not to be removed.
221+
final reaction3 = Reaction.fromJson(eventReaction.toJson()
222+
..update('emoji_code', (_) => '1f6e0')
223+
..update('emoji_name', (_) => 'working_on_it'));
224+
225+
// Same user, same emojiCode, different emojiName. To be removed: servers
226+
// key on user, message, reaction type, and emoji code, but not emoji name,
227+
// so we follow that:
228+
// https://github.com/zulip/zulip-flutter/pull/256#discussion_r1284865099
229+
final reaction4 = Reaction.fromJson(eventReaction.toJson()
230+
..update('emoji_name', (_) => 'tools'));
231+
232+
final originalMessage = eg.streamMessage(id: 243, stream: stream,
233+
reactions: [eventReaction, reaction2, reaction3, reaction4]);
234+
final messageList = await messageListViewWithMessages([originalMessage], stream, narrow);
235+
236+
final message = messageList.messages.single;
237+
check(message).reactions.not(it()..jsonEquals([reaction2, reaction3]));
238+
239+
bool listenersNotified = false;
240+
messageList.addListener(() { listenersNotified = true; });
241+
242+
messageList.maybeUpdateMessageReactions(
243+
mkEvent(eventReaction, ReactionOp.remove, originalMessage.id));
244+
245+
check(listenersNotified).isTrue();
246+
check(messageList.messages.single)
247+
..identicalTo(message)
248+
..reactions.jsonEquals([reaction2, reaction3]);
249+
});
250+
251+
test('remove reaction; message is not in list', () async {
252+
final someMessage = eg.streamMessage(id: 1, reactions: [eg.unicodeEmojiReaction]);
253+
final messageList = await messageListViewWithMessages([someMessage], stream, narrow);
254+
255+
check(messageList.messages.single).reactions.jsonEquals([eg.unicodeEmojiReaction]);
256+
257+
bool listenersNotified = false;
258+
messageList.addListener(() { listenersNotified = true; });
259+
260+
messageList.maybeUpdateMessageReactions(
261+
mkEvent(eg.unicodeEmojiReaction, ReactionOp.remove, 1000));
262+
263+
check(listenersNotified).isFalse();
264+
check(messageList.messages.single).reactions.jsonEquals([eg.unicodeEmojiReaction]);
265+
});
266+
});
165267
});
166268
}

0 commit comments

Comments
 (0)