Skip to content

Add Reaction class; uncomment Message.reactions and update on events #256

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

38 changes: 37 additions & 1 deletion lib/api/model/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ sealed class Message {
bool isMeMessage;
int? lastEditTimestamp;

// final List<Reaction> reactions; // TODO handle
final List<Reaction> reactions;
final int recipientId;
final String senderEmail;
final String senderFullName;
Expand All @@ -282,6 +282,7 @@ sealed class Message {
required this.id,
required this.isMeMessage,
this.lastEditTimestamp,
required this.reactions,
required this.recipientId,
required this.senderEmail,
required this.senderFullName,
Expand Down Expand Up @@ -320,6 +321,7 @@ class StreamMessage extends Message {
required super.id,
required super.isMeMessage,
super.lastEditTimestamp,
required super.reactions,
required super.recipientId,
required super.senderEmail,
required super.senderFullName,
Expand Down Expand Up @@ -421,6 +423,7 @@ class DmMessage extends Message {
required super.id,
required super.isMeMessage,
super.lastEditTimestamp,
required super.reactions,
required super.recipientId,
required super.senderEmail,
required super.senderFullName,
Expand All @@ -440,3 +443,36 @@ class DmMessage extends Message {
@override
Map<String, dynamic> toJson() => _$DmMessageToJson(this);
}

/// As in [Message.reactions].
@JsonSerializable(fieldRename: FieldRename.snake)
class Reaction {
Comment on lines +448 to +449
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@JsonSerializable(fieldRename: FieldRename.snake)
class Reaction {
/// As in [Message.reactions].
@JsonSerializable(fieldRename: FieldRename.snake)
class Reaction {

This way there's a breadcrumb trail to find the relevant API docs.

final String emojiName;
final String emojiCode;
final ReactionType reactionType;
final int userId;
// final Map<String, dynamic> user; // deprecated; ignore

Reaction({
required this.emojiName,
required this.emojiCode,
required this.reactionType,
required this.userId,
});

factory Reaction.fromJson(Map<String, dynamic> json) =>
_$ReactionFromJson(json);

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

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

/// As in [Reaction.reactionType].
@JsonEnum(fieldRename: FieldRename.snake)
enum ReactionType {
unicodeEmoji,
realmEmoji,
zulipExtraEmoji;
}
28 changes: 28 additions & 0 deletions lib/api/model/model.g.dart

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

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
11 changes: 10 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,6 +147,7 @@ StreamMessage streamMessage({
..._messagePropertiesFromContent(content, contentMarkdown),
'display_recipient': effectiveStream.name,
'stream_id': effectiveStream.streamId,
'reactions': reactions?.map((r) => r.toJson()).toList() ?? [],
'flags': flags ?? [],
'id': id ?? 1234567, // TODO generate example IDs
'last_edit_timestamp': lastEditTimestamp,
Expand Down Expand Up @@ -176,7 +178,7 @@ DmMessage dmMessage({
'display_recipient': [from, ...to]
.map((u) => {'id': u.userId, 'email': u.email, 'full_name': u.fullName})
.toList(growable: false),

'reactions': [],
'flags': flags ?? [],
'id': id ?? 1234567, // TODO generate example IDs
'last_edit_timestamp': lastEditTimestamp,
Expand All @@ -186,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
Loading