Skip to content

Commit

Permalink
api: Add reaction events
Browse files Browse the repository at this point in the history
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
  • Loading branch information
chrisbobbe committed Aug 8, 2023
1 parent 2fd0c4c commit e119b93
Show file tree
Hide file tree
Showing 8 changed files with 253 additions and 2 deletions.
45 changes: 45 additions & 0 deletions lib/api/model/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<String, dynamic> 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<String, dynamic> json) =>
_$ReactionEventFromJson(json);

@override
Map<String, dynamic> 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 {
Expand Down
34 changes: 34 additions & 0 deletions lib/api/model/events.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,9 @@ class Reaction {
_$ReactionFromJson(json);

Map<String, dynamic> toJson() => _$ReactionToJson(this);

@override
String toString() => 'Reaction(emojiName: $emojiName, emojiCode: $emojiCode, reactionType: $reactionType, userId: $userId)';
}

/// As in [Reaction.reactionType].
Expand Down
26 changes: 26 additions & 0 deletions lib/model/message_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 29 additions & 1 deletion test/api/model/model_checks.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 expectedJson = expected.toJson();
expectedJson['reactions'] = it()..isA<List<Reaction>>().jsonEquals(expected.reactions);
toJson.deepEquals(expectedJson);
}

Subject<String> get content => has((e) => e.content, 'content');
Subject<bool> get isMeMessage => has((e) => e.isMeMessage, 'isMeMessage');
Subject<int?> get lastEditTimestamp => has((e) => e.lastEditTimestamp, 'lastEditTimestamp');
Subject<List<Reaction>> get reactions => has((e) => e.reactions, 'reactions');
Subject<List<String>> get flags => has((e) => e.flags, 'flags');

// TODO accessors for other fields
}

extension ReactionsChecks on Subject<List<Reaction>> {
void deepEquals(_) {
throw UnimplementedError('Tried to call [Subject<List<Reaction>>.deepEquals]. Use jsonEquals instead.');
}

void jsonEquals(List<Reaction> expected) {
// (cast, to bypass this extension's deepEquals implementation, which throws)
// ignore: unnecessary_cast
(this as Subject<List>).deepEquals(expected.map((r) => it()..isA<Reaction>().jsonEquals(r)));
}
}

extension ReactionChecks on Subject<Reaction> {
Subject<Map<String, dynamic>> get toJson => has((r) => r.toJson(), 'toJson');

void jsonEquals(Reaction expected) {
toJson.deepEquals(expected.toJson());
}

Subject<String> get emojiName => has((r) => r.emojiName, 'emojiName');
Subject<String> get emojiCode => has((r) => r.emojiCode, 'emojiCode');
Subject<ReactionType> get reactionType => has((r) => r.reactionType, 'reactionType');
Subject<int> get userId => has((r) => r.userId, 'userId');
}

// TODO similar extensions for other types in model
10 changes: 9 additions & 1 deletion test/example_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ StreamMessage streamMessage({
String? content,
String? contentMarkdown,
int? lastEditTimestamp,
List<Reaction>? reactions,
List<String>? flags,
}) {
final effectiveStream = stream ?? _stream();
Expand All @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
102 changes: 102 additions & 0 deletions test/model/message_list_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(stream: stream, reactions: []);
final messageList = await messageListViewWithMessages([originalMessage], stream, narrow);

final message = messageList.messages.single;
check(message).reactions.jsonEquals([]);

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 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'));

final originalMessage = eg.streamMessage(stream: stream,
reactions: [reaction2, reaction3, reaction4]);
final messageList = await messageListViewWithMessages([originalMessage], stream, narrow);

final message = messageList.messages.single;

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]);
});
});
});
}

0 comments on commit e119b93

Please sign in to comment.