From 1da3c2fbc4458d238865e6de8cd5988e87f66d99 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 2 Aug 2023 18:09:12 -0700 Subject: [PATCH] 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 --- lib/api/model/events.dart | 45 +++++++++++++ lib/api/model/events.g.dart | 34 ++++++++++ lib/api/model/model.dart | 3 + lib/model/message_list.dart | 26 ++++++++ lib/model/store.dart | 5 ++ test/api/model/model_checks.dart | 30 ++++++++- test/example_data.dart | 10 ++- test/model/message_list_test.dart | 102 ++++++++++++++++++++++++++++++ 8 files changed, 253 insertions(+), 2 deletions(-) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 47aa24256b4..f09f0b24b15 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -33,6 +33,7 @@ sealed class Event { case 'message': return MessageEvent.fromJson(json); case 'update_message': return UpdateMessageEvent.fromJson(json); case 'delete_message': return DeleteMessageEvent.fromJson(json); + case 'reaction': return ReactionEvent.fromJson(json); case 'heartbeat': return HeartbeatEvent.fromJson(json); // TODO add many more event types default: return UnexpectedEvent.fromJson(json); @@ -370,6 +371,50 @@ enum MessageType { private; } +/// A Zulip event of type `reaction`, with op `add` or `remove`. +/// +/// See: +/// https://zulip.com/api/get-events#reaction-add +/// https://zulip.com/api/get-events#reaction-remove +@JsonSerializable(fieldRename: FieldRename.snake) +class ReactionEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'reaction'; + + final ReactionOp op; + + final String emojiName; + final String emojiCode; + final ReactionType reactionType; + final int userId; + // final Map user; // deprecated; ignore + final int messageId; + + ReactionEvent({ + required super.id, + required this.op, + required this.emojiName, + required this.emojiCode, + required this.reactionType, + required this.userId, + required this.messageId, + }); + + factory ReactionEvent.fromJson(Map json) => + _$ReactionEventFromJson(json); + + @override + Map toJson() => _$ReactionEventToJson(this); +} + +/// The type of [ReactionEvent.op]. +@JsonEnum(fieldRename: FieldRename.snake) +enum ReactionOp { + add, + remove, +} + /// A Zulip event of type `heartbeat`: https://zulip.com/api/get-events#heartbeat @JsonSerializable(fieldRename: FieldRename.snake) class HeartbeatEvent extends Event { diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 68aa0c4c7a9..5183ec3145e 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -209,6 +209,40 @@ const _$MessageTypeEnumMap = { MessageType.private: 'private', }; +ReactionEvent _$ReactionEventFromJson(Map json) => + ReactionEvent( + id: json['id'] as int, + op: $enumDecode(_$ReactionOpEnumMap, json['op']), + emojiName: json['emoji_name'] as String, + emojiCode: json['emoji_code'] as String, + reactionType: $enumDecode(_$ReactionTypeEnumMap, json['reaction_type']), + userId: json['user_id'] as int, + messageId: json['message_id'] as int, + ); + +Map _$ReactionEventToJson(ReactionEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'op': _$ReactionOpEnumMap[instance.op]!, + 'emoji_name': instance.emojiName, + 'emoji_code': instance.emojiCode, + 'reaction_type': _$ReactionTypeEnumMap[instance.reactionType]!, + 'user_id': instance.userId, + 'message_id': instance.messageId, + }; + +const _$ReactionOpEnumMap = { + ReactionOp.add: 'add', + ReactionOp.remove: 'remove', +}; + +const _$ReactionTypeEnumMap = { + ReactionType.unicodeEmoji: 'unicode_emoji', + ReactionType.realmEmoji: 'realm_emoji', + ReactionType.zulipExtraEmoji: 'zulip_extra_emoji', +}; + HeartbeatEvent _$HeartbeatEventFromJson(Map json) => HeartbeatEvent( id: json['id'] as int, diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index bb8af8ca45e..30b9af55282 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -464,6 +464,9 @@ class Reaction { _$ReactionFromJson(json); Map toJson() => _$ReactionToJson(this); + + @override + String toString() => 'Reaction(emojiName: $emojiName, emojiCode: $emojiCode, reactionType: $reactionType, userId: $userId)'; } /// As in [Reaction.reactionType]. diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 26d01017c2f..9ba6d30ab56 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -151,6 +151,32 @@ class MessageListView extends ChangeNotifier { notifyListeners(); } + void maybeUpdateMessageReactions(ReactionEvent event) { + final index = findMessageWithId(event.messageId); + if (index == -1) { + return; + } + + final message = messages[index]; + switch (event.op) { + case ReactionOp.add: + message.reactions.add(Reaction( + emojiName: event.emojiName, + emojiCode: event.emojiCode, + reactionType: event.reactionType, + userId: event.userId, + )); + case ReactionOp.remove: + message.reactions.removeWhere((r) { + return r.emojiCode == event.emojiCode + && r.reactionType == event.reactionType + && r.userId == event.userId; + }); + } + + notifyListeners(); + } + /// Called when the app is reassembled during debugging, e.g. for hot reload. /// /// This will redo from scratch any computations we can, such as parsing diff --git a/lib/model/store.dart b/lib/model/store.dart index f5121f3da82..9132d30c20e 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -278,6 +278,11 @@ class PerAccountStore extends ChangeNotifier { } else if (event is DeleteMessageEvent) { assert(debugLog("server event: delete_message ${event.messageIds}")); // TODO handle + } else if (event is ReactionEvent) { + assert(debugLog("server event: reaction/${event.op}")); + for (final view in _messageListViews) { + view.maybeUpdateMessageReactions(event); + } } else if (event is UnexpectedEvent) { assert(debugLog("server event: ${jsonEncode(event.toJson())}")); // TODO log better } else { diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index a049df975a3..7e6fc3573de 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -5,15 +5,43 @@ extension MessageChecks on Subject { Subject> get toJson => has((e) => e.toJson(), 'toJson'); void jsonEquals(Message expected) { - toJson.deepEquals(expected.toJson()); + final expected_ = expected.toJson(); + expected_['reactions'] = it()..isA>().jsonEquals(expected.reactions); + toJson.deepEquals(expected_); } Subject get content => has((e) => e.content, 'content'); Subject get isMeMessage => has((e) => e.isMeMessage, 'isMeMessage'); Subject get lastEditTimestamp => has((e) => e.lastEditTimestamp, 'lastEditTimestamp'); + Subject> get reactions => has((e) => e.reactions, 'reactions'); Subject> get flags => has((e) => e.flags, 'flags'); // TODO accessors for other fields } +extension ReactionsChecks on Subject> { + void deepEquals(_) { + throw UnimplementedError('Tried to call [Subject>.deepEquals]. Use jsonEquals instead.'); + } + + void jsonEquals(List expected) { + // (cast, to bypass this extension's deepEquals implementation, which throws) + // ignore: unnecessary_cast + (this as Subject).deepEquals(expected.map((r) => it()..isA().jsonEquals(r))); + } +} + +extension ReactionChecks on Subject { + Subject> get toJson => has((r) => r.toJson(), 'toJson'); + + void jsonEquals(Reaction expected) { + toJson.deepEquals(expected.toJson()); + } + + Subject get emojiName => has((r) => r.emojiName, 'emojiName'); + Subject get emojiCode => has((r) => r.emojiCode, 'emojiCode'); + Subject get reactionType => has((r) => r.reactionType, 'reactionType'); + Subject get userId => has((r) => r.userId, 'userId'); +} + // TODO similar extensions for other types in model diff --git a/test/example_data.dart b/test/example_data.dart index 70cebe65364..460c2981b69 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -132,6 +132,7 @@ StreamMessage streamMessage({ String? content, String? contentMarkdown, int? lastEditTimestamp, + List? reactions, List? flags, }) { final effectiveStream = stream ?? _stream(); @@ -146,7 +147,7 @@ StreamMessage streamMessage({ ..._messagePropertiesFromContent(content, contentMarkdown), 'display_recipient': effectiveStream.name, 'stream_id': effectiveStream.streamId, - 'reactions': [], + 'reactions': reactions?.map((r) => r.toJson()).toList() ?? [], 'flags': flags ?? [], 'id': id ?? 1234567, // TODO generate example IDs 'last_edit_timestamp': lastEditTimestamp, @@ -187,6 +188,13 @@ DmMessage dmMessage({ }); } +Reaction unicodeEmojiReaction = Reaction( + emojiName: 'thumbs_up', + emojiCode: '1f44d', + reactionType: ReactionType.unicodeEmoji, + userId: selfUser.userId, +); + // TODO example data for many more types InitialSnapshot initialSnapshot({ diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 6893938a234..d0e618be79f 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -162,5 +162,107 @@ void main() async { test('rendering-only update does not change timestamp (for old server versions)', () async { await checkRenderingOnly(legacy: true); }); + + group('ReactionEvent handling', () { + ReactionEvent mkEvent(Reaction reaction, ReactionOp op, int messageId) { + return ReactionEvent( + id: 1, + op: op, + emojiName: reaction.emojiName, + emojiCode: reaction.emojiCode, + reactionType: reaction.reactionType, + userId: reaction.userId, + messageId: messageId, + ); + } + + test('add reaction', () async { + final originalMessage = eg.streamMessage(id: 243, stream: stream, reactions: []); + final messageList = await messageListViewWithMessages([originalMessage], stream, narrow); + + final message = messageList.messages.single; + check(message).reactions.not(it()..jsonEquals([eg.unicodeEmojiReaction])); + + bool listenersNotified = false; + messageList.addListener(() { listenersNotified = true; }); + + messageList.maybeUpdateMessageReactions( + mkEvent(eg.unicodeEmojiReaction, ReactionOp.add, originalMessage.id)); + + check(listenersNotified).isTrue(); + check(messageList.messages.single) + ..identicalTo(message) + ..reactions.jsonEquals([eg.unicodeEmojiReaction]); + }); + + 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([]); + + bool listenersNotified = false; + messageList.addListener(() { listenersNotified = true; }); + + messageList.maybeUpdateMessageReactions( + mkEvent(eg.unicodeEmojiReaction, ReactionOp.add, 1000)); + + check(listenersNotified).isFalse(); + check(messageList.messages.single).reactions.jsonEquals([]); + }); + + test('remove reaction', () async { + final eventReaction = Reaction(reactionType: ReactionType.unicodeEmoji, + emojiName: 'wave', emojiCode: '1f44b', userId: 1); + + // Same emoji, different user. Not to be removed. + final reaction2 = Reaction.fromJson(eventReaction.toJson()..update('user_id', (_) => 2)); + + // Same user, different emoji. Not to be removed. + final reaction3 = Reaction.fromJson(eventReaction.toJson() + ..update('emoji_code', (_) => '1f6e0') + ..update('emoji_name', (_) => 'working_on_it')); + + // Same user, same emojiCode, different emojiName. To be removed: servers + // key on user, message, reaction type, and emoji code, but not emoji name, + // so we follow that: + // https://github.com/zulip/zulip-flutter/pull/256#discussion_r1284865099 + final reaction4 = Reaction.fromJson(eventReaction.toJson() + ..update('emoji_name', (_) => 'tools')); + + final originalMessage = eg.streamMessage(id: 243, stream: stream, + reactions: [eventReaction, reaction2, reaction3, reaction4]); + final messageList = await messageListViewWithMessages([originalMessage], stream, narrow); + + final message = messageList.messages.single; + check(message).reactions.not(it()..jsonEquals([reaction2, reaction3])); + + bool listenersNotified = false; + messageList.addListener(() { listenersNotified = true; }); + + messageList.maybeUpdateMessageReactions( + mkEvent(eventReaction, ReactionOp.remove, originalMessage.id)); + + check(listenersNotified).isTrue(); + check(messageList.messages.single) + ..identicalTo(message) + ..reactions.jsonEquals([reaction2, reaction3]); + }); + + 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]); + + bool listenersNotified = false; + messageList.addListener(() { listenersNotified = true; }); + + messageList.maybeUpdateMessageReactions( + mkEvent(eg.unicodeEmojiReaction, ReactionOp.remove, 1000)); + + check(listenersNotified).isFalse(); + check(messageList.messages.single).reactions.jsonEquals([eg.unicodeEmojiReaction]); + }); + }); }); }