Skip to content

Commit 272c366

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 22aa268 commit 272c366

File tree

8 files changed

+261
-1
lines changed

8 files changed

+261
-1
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: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,21 @@ 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)';
470+
471+
@override
472+
bool operator ==(Object other) {
473+
if (other is! Reaction) return false;
474+
return other.emojiName == emojiName
475+
&& other.emojiCode == emojiCode
476+
&& other.reactionType == reactionType
477+
&& other.userId == userId;
478+
}
479+
480+
@override
481+
int get hashCode => Object.hash('Reaction', emojiName, emojiCode, reactionType, userId);
467482
}
468483

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

lib/model/message_list.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,29 @@ 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+
final reaction = Reaction(
162+
emojiName: event.emojiName,
163+
emojiCode: event.emojiCode,
164+
reactionType: event.reactionType,
165+
userId: event.userId,
166+
);
167+
switch (event.op) {
168+
case ReactionOp.add:
169+
message.reactions.add(reaction);
170+
case ReactionOp.remove:
171+
message.reactions.removeWhere((r) => r == reaction);
172+
}
173+
174+
notifyListeners();
175+
}
176+
154177
/// Called when the app is reassembled during debugging, e.g. for hot reload.
155178
///
156179
/// 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ extension MessageChecks on Subject<Message> {
1111
Subject<String> get content => has((e) => e.content, 'content');
1212
Subject<bool> get isMeMessage => has((e) => e.isMeMessage, 'isMeMessage');
1313
Subject<int?> get lastEditTimestamp => has((e) => e.lastEditTimestamp, 'lastEditTimestamp');
14+
Subject<List<Reaction>> get reactions => has((e) => e.reactions, 'reactions');
1415
Subject<List<String>> get flags => has((e) => e.flags, 'flags');
1516

1617
// TODO accessors for other fields

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? topic,
133133
String? content,
134134
String? contentMarkdown,
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
'subject': topic ?? 'example topic',
@@ -184,6 +185,13 @@ DmMessage dmMessage({
184185
});
185186
}
186187

188+
Reaction unicodeEmojiReaction = Reaction(
189+
emojiName: 'thumbs_up',
190+
emojiCode: '1f44d',
191+
reactionType: ReactionType.unicodeEmoji,
192+
userId: selfUser.userId,
193+
);
194+
187195
// TODO example data for many more types
188196

189197
InitialSnapshot initialSnapshot({

test/model/message_list_test.dart

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,5 +221,134 @@ void main() async {
221221
..content.equals(newContent)
222222
..lastEditTimestamp.equals(oldTimestamp);
223223
});
224+
225+
group('ReactionEvent handling', () {
226+
test('add reaction', () async {
227+
final store = await setupStore(stream);
228+
final originalMessage = eg.streamMessage(id: 243, stream: stream, reactions: []);
229+
final messageList = await messageListViewWithMessages([originalMessage], store, narrow);
230+
231+
final event = ReactionEvent(
232+
id: 1,
233+
op: ReactionOp.add,
234+
emojiName: eg.unicodeEmojiReaction.emojiName,
235+
emojiCode: eg.unicodeEmojiReaction.emojiCode,
236+
reactionType: eg.unicodeEmojiReaction.reactionType,
237+
userId: eg.unicodeEmojiReaction.userId,
238+
messageId: originalMessage.id,
239+
);
240+
241+
final message = messageList.messages.single;
242+
check(message).reactions.deepEquals([]);
243+
244+
bool listenersNotified = false;
245+
246+
messageList.addListener(() { listenersNotified = true; });
247+
messageList.maybeUpdateMessageReactions(event);
248+
249+
check(listenersNotified).isTrue();
250+
251+
check(message)
252+
..identicalTo(messageList.messages.single)
253+
..reactions.deepEquals([eg.unicodeEmojiReaction]);
254+
});
255+
256+
test('add reaction; message is not in list', () async {
257+
final store = await setupStore(stream);
258+
final someMessage = eg.streamMessage(id: 1, reactions: []);
259+
final messageList = await messageListViewWithMessages([someMessage], store, narrow);
260+
261+
final event = ReactionEvent(
262+
id: 1,
263+
op: ReactionOp.add,
264+
emojiName: eg.unicodeEmojiReaction.emojiName,
265+
emojiCode: eg.unicodeEmojiReaction.emojiCode,
266+
reactionType: eg.unicodeEmojiReaction.reactionType,
267+
userId: eg.unicodeEmojiReaction.userId,
268+
messageId: 1000,
269+
);
270+
271+
check(messageList.messages.single).reactions.deepEquals([]);
272+
273+
bool listenersNotified = false;
274+
275+
messageList.addListener(() { listenersNotified = true; });
276+
messageList.maybeUpdateMessageReactions(event);
277+
278+
check(listenersNotified).isFalse();
279+
280+
check(messageList.messages.single)
281+
..identicalTo(messageList.messages.single)
282+
..reactions.deepEquals([]);
283+
});
284+
285+
test('remove reaction', () async {
286+
final store = await setupStore(stream);
287+
288+
final reaction1 = Reaction(reactionType: ReactionType.unicodeEmoji,
289+
emojiName: 'wave', emojiCode: '1f44b', userId: 1);
290+
final reaction2 = Reaction(reactionType: ReactionType.unicodeEmoji, // same emoji, different user
291+
emojiName: 'wave', emojiCode: '1f44b', userId: 2);
292+
final reaction3 = Reaction(reactionType: ReactionType.unicodeEmoji, // same user, different emoji
293+
emojiName: 'working_on_it', emojiCode: '1f6e0', userId: 1);
294+
295+
final originalMessage = eg.streamMessage(id: 243, stream: stream,
296+
reactions: [reaction1, reaction2, reaction3]);
297+
final messageList = await messageListViewWithMessages([originalMessage], store, narrow);
298+
299+
final event = ReactionEvent(
300+
id: 1,
301+
op: ReactionOp.remove,
302+
emojiName: reaction1.emojiName,
303+
emojiCode: reaction1.emojiCode,
304+
reactionType: reaction1.reactionType,
305+
userId: reaction1.userId,
306+
messageId: originalMessage.id,
307+
);
308+
309+
final message = messageList.messages.single;
310+
check(message).reactions.deepEquals([reaction1, reaction2, reaction3]);
311+
312+
bool listenersNotified = false;
313+
314+
messageList.addListener(() { listenersNotified = true; });
315+
messageList.maybeUpdateMessageReactions(event);
316+
317+
check(listenersNotified).isTrue();
318+
319+
check(message)
320+
..identicalTo(messageList.messages.single)
321+
..reactions.deepEquals([reaction2, reaction3]);
322+
});
323+
324+
test('remove reaction; message is not in list', () async {
325+
final store = await setupStore(stream);
326+
final someMessage = eg.streamMessage(id: 1, reactions: []);
327+
final messageList = await messageListViewWithMessages([someMessage], store, narrow);
328+
329+
final event = ReactionEvent(
330+
id: 1,
331+
op: ReactionOp.remove,
332+
emojiName: eg.unicodeEmojiReaction.emojiName,
333+
emojiCode: eg.unicodeEmojiReaction.emojiCode,
334+
reactionType: eg.unicodeEmojiReaction.reactionType,
335+
userId: eg.unicodeEmojiReaction.userId,
336+
messageId: 1000,
337+
);
338+
339+
check(messageList.messages.single).reactions.deepEquals([]);
340+
341+
bool listenersNotified = false;
342+
343+
messageList.addListener(() { listenersNotified = true; });
344+
messageList.maybeUpdateMessageReactions(event);
345+
346+
check(listenersNotified).isFalse();
347+
348+
check(messageList.messages.single)
349+
..identicalTo(messageList.messages.single)
350+
..reactions.deepEquals([]);
351+
});
352+
});
224353
});
225354
}

0 commit comments

Comments
 (0)