Skip to content

Commit 15099f4

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

File tree

8 files changed

+249
-2
lines changed

8 files changed

+249
-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 expectedJson = expected.toJson();
9+
expectedJson['reactions'] = it()..isA<List<Reaction>>().jsonEquals(expected.reactions);
10+
toJson.deepEquals(expectedJson);
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: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,5 +162,103 @@ 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(stream: stream, reactions: []);
181+
final messageList = await messageListViewWithMessages([originalMessage], stream, narrow);
182+
183+
final message = messageList.messages.single;
184+
185+
bool listenersNotified = false;
186+
messageList.addListener(() { listenersNotified = true; });
187+
188+
messageList.maybeUpdateMessageReactions(
189+
mkEvent(eg.unicodeEmojiReaction, ReactionOp.add, originalMessage.id));
190+
191+
check(listenersNotified).isTrue();
192+
check(messageList.messages.single)
193+
..identicalTo(message)
194+
..reactions.jsonEquals([eg.unicodeEmojiReaction]);
195+
});
196+
197+
test('add reaction; message is not in list', () async {
198+
final someMessage = eg.streamMessage(id: 1, reactions: []);
199+
final messageList = await messageListViewWithMessages([someMessage], stream, narrow);
200+
201+
bool listenersNotified = false;
202+
messageList.addListener(() { listenersNotified = true; });
203+
204+
messageList.maybeUpdateMessageReactions(
205+
mkEvent(eg.unicodeEmojiReaction, ReactionOp.add, 1000));
206+
207+
check(listenersNotified).isFalse();
208+
check(messageList.messages.single).reactions.jsonEquals([]);
209+
});
210+
211+
test('remove reaction', () async {
212+
final eventReaction = Reaction(reactionType: ReactionType.unicodeEmoji,
213+
emojiName: 'wave', emojiCode: '1f44b', userId: 1);
214+
215+
// Same emoji, different user. Not to be removed.
216+
final reaction2 = Reaction.fromJson(eventReaction.toJson()
217+
..['user_id'] = 2);
218+
219+
// Same user, different emoji. Not to be removed.
220+
final reaction3 = Reaction.fromJson(eventReaction.toJson()
221+
..['emoji_code'] = '1f6e0'
222+
..['emoji_name'] = 'working_on_it');
223+
224+
// Same user, same emojiCode, different emojiName. To be removed: servers
225+
// key on user, message, reaction type, and emoji code, but not emoji name.
226+
// So we mimic that behavior; see discussion:
227+
// https://github.com/zulip/zulip-flutter/pull/256#discussion_r1284865099
228+
final reaction4 = Reaction.fromJson(eventReaction.toJson()
229+
..['emoji_name'] = 'hello');
230+
231+
final originalMessage = eg.streamMessage(stream: stream,
232+
reactions: [reaction2, reaction3, reaction4]);
233+
final messageList = await messageListViewWithMessages([originalMessage], stream, narrow);
234+
235+
final message = messageList.messages.single;
236+
237+
bool listenersNotified = false;
238+
messageList.addListener(() { listenersNotified = true; });
239+
240+
messageList.maybeUpdateMessageReactions(
241+
mkEvent(eventReaction, ReactionOp.remove, originalMessage.id));
242+
243+
check(listenersNotified).isTrue();
244+
check(messageList.messages.single)
245+
..identicalTo(message)
246+
..reactions.jsonEquals([reaction2, reaction3]);
247+
});
248+
249+
test('remove reaction; message is not in list', () async {
250+
final someMessage = eg.streamMessage(id: 1, reactions: [eg.unicodeEmojiReaction]);
251+
final messageList = await messageListViewWithMessages([someMessage], stream, narrow);
252+
253+
bool listenersNotified = false;
254+
messageList.addListener(() { listenersNotified = true; });
255+
256+
messageList.maybeUpdateMessageReactions(
257+
mkEvent(eg.unicodeEmojiReaction, ReactionOp.remove, 1000));
258+
259+
check(listenersNotified).isFalse();
260+
check(messageList.messages.single).reactions.jsonEquals([eg.unicodeEmojiReaction]);
261+
});
262+
});
165263
});
166264
}

0 commit comments

Comments
 (0)