diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index c604ecdb0c..344be5950f 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -341,7 +341,7 @@ class UpdateMessageEvent extends Event { final bool? renderingOnly; // TODO(server-5) final int messageId; final List messageIds; - final List flags; // TODO enum + final List flags; final int? editTimestamp; // TODO(server-5) final String? streamName; final int? streamId; diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index fe1e5b8367..53aff1c708 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -166,7 +166,9 @@ UpdateMessageEvent _$UpdateMessageEventFromJson(Map json) => messageId: json['message_id'] as int, messageIds: (json['message_ids'] as List).map((e) => e as int).toList(), - flags: (json['flags'] as List).map((e) => e as String).toList(), + flags: (json['flags'] as List) + .map((e) => $enumDecode(_$MessageFlagEnumMap, e)) + .toList(), editTimestamp: json['edit_timestamp'] as int?, streamName: json['stream_name'] as String?, streamId: json['stream_id'] as int?, @@ -204,6 +206,17 @@ Map _$UpdateMessageEventToJson(UpdateMessageEvent instance) => 'is_me_message': instance.isMeMessage, }; +const _$MessageFlagEnumMap = { + MessageFlag.read: 'read', + MessageFlag.starred: 'starred', + MessageFlag.collapsed: 'collapsed', + MessageFlag.mentioned: 'mentioned', + MessageFlag.wildcardMentioned: 'wildcard_mentioned', + MessageFlag.hasAlertWord: 'has_alert_word', + MessageFlag.historical: 'historical', + MessageFlag.unknown: 'unknown', +}; + const _$PropagateModeEnumMap = { PropagateMode.changeOne: 'change_one', PropagateMode.changeLater: 'change_later', diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 3ebd8fee16..b74a713f6f 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -271,10 +271,16 @@ sealed class Message { // final List topicLinks; // TODO handle // final string type; // handled by runtime type of object - List flags; // TODO enum + @JsonKey(fromJson: _flagsFromJson) + List flags; // Unrecognized flags won't roundtrip through {to,from}Json. final String? matchContent; final String? matchSubject; + static List _flagsFromJson(dynamic json) { + final list = json as List; + return list.map((raw) => MessageFlag.fromRawString(raw as String)).toList(); + } + Message({ required this.client, required this.content, @@ -305,6 +311,32 @@ sealed class Message { Map toJson(); } +/// As in [Message.flags]. +@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) +enum MessageFlag { + read, + starred, + collapsed, + mentioned, + wildcardMentioned, + hasAlertWord, + historical, + unknown; + + /// Get a [MessageFlag] from a raw, snake-case string. + /// + /// Will be [MessageFlag.unknown] if we don't recognize the string. + /// + /// Example: + /// 'wildcard_mentioned' -> Flag.wildcardMentioned + static MessageFlag fromRawString(String raw) => _byRawString[raw] ?? unknown; + + // _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake` + static final _byRawString = _$MessageFlagEnumMap.map((key, value) => MapEntry(value, key)); + + String toJson() => _$MessageFlagEnumMap[this]!; +} + @JsonSerializable(fieldRename: FieldRename.snake) class StreamMessage extends Message { @override diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 452a5a4fc4..e8d1e7eb24 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -196,7 +196,7 @@ StreamMessage _$StreamMessageFromJson(Map json) => senderRealmStr: json['sender_realm_str'] as String, subject: json['subject'] as String, timestamp: json['timestamp'] as int, - flags: (json['flags'] as List).map((e) => e as String).toList(), + flags: Message._flagsFromJson(json['flags']), matchContent: json['match_content'] as String?, matchSubject: json['match_subject'] as String?, displayRecipient: json['display_recipient'] as String, @@ -257,7 +257,7 @@ DmMessage _$DmMessageFromJson(Map json) => DmMessage( senderRealmStr: json['sender_realm_str'] as String, subject: json['subject'] as String, timestamp: json['timestamp'] as int, - flags: (json['flags'] as List).map((e) => e as String).toList(), + flags: Message._flagsFromJson(json['flags']), matchContent: json['match_content'] as String?, matchSubject: json['match_subject'] as String?, displayRecipient: const DmRecipientListConverter() @@ -306,3 +306,14 @@ const _$ReactionTypeEnumMap = { ReactionType.realmEmoji: 'realm_emoji', ReactionType.zulipExtraEmoji: 'zulip_extra_emoji', }; + +const _$MessageFlagEnumMap = { + MessageFlag.read: 'read', + MessageFlag.starred: 'starred', + MessageFlag.collapsed: 'collapsed', + MessageFlag.mentioned: 'mentioned', + MessageFlag.wildcardMentioned: 'wildcard_mentioned', + MessageFlag.hasAlertWord: 'has_alert_word', + MessageFlag.historical: 'historical', + MessageFlag.unknown: 'unknown', +}; diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart index 6076612561..c71cfe5407 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -161,6 +161,13 @@ class DmNarrow extends Narrow implements SendableNarrow { ); } + factory DmNarrow.withUser(int userId, {required int selfUserId}) { + return DmNarrow( + allRecipientIds: {userId, selfUserId}.toList()..sort(), + selfUserId: selfUserId, + ); + } + /// The user IDs of everyone in the conversation, sorted. /// /// Each message in the conversation is sent by one of these users diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index e86cc1c4c4..d5e2be1708 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -2,6 +2,7 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; import '../../example_data.dart' as eg; import '../../stdlib_checks.dart'; @@ -11,15 +12,15 @@ import 'model_checks.dart'; void main() { test('message: move flags into message object', () { final message = eg.streamMessage(); - MessageEvent mkEvent(List flags) => Event.fromJson({ + MessageEvent mkEvent(List flags) => Event.fromJson({ 'type': 'message', 'id': 1, 'message': message.toJson()..remove('flags'), - 'flags': flags, + 'flags': flags.map((f) => f.toJson()).toList(), }) as MessageEvent; check(mkEvent(message.flags)).message.jsonEquals(message); check(mkEvent([])).message.flags.deepEquals([]); - check(mkEvent(['read'])).message.flags.deepEquals(['read']); + check(mkEvent([MessageFlag.read])).message.flags.deepEquals([MessageFlag.read]); }); test('user_settings: all known settings have event handling', () { diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 7067e53d84..13bab49afd 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -6,7 +6,7 @@ extension MessageChecks on Subject { 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'); + Subject> get flags => has((e) => e.flags, 'flags'); // TODO accessors for other fields } diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index 6ccf59c545..e97293427b 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -3,6 +3,8 @@ import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/model.dart'; import '../../example_data.dart' as eg; +import '../../stdlib_checks.dart'; +import 'model_checks.dart'; void main() { group('User', () { @@ -47,6 +49,22 @@ void main() { }); }); + group('Message', () { + test('no crash on unrecognized flag', () { + final m1 = Message.fromJson( + (deepToJson(eg.streamMessage()) as Map) + ..['flags'] = ['read', 'something_unknown'], + ); + check(m1).flags.deepEquals([MessageFlag.read, MessageFlag.unknown]); + + final m2 = Message.fromJson( + (deepToJson(eg.dmMessage(from: eg.selfUser, to: [eg.otherUser])) as Map) + ..['flags'] = ['read', 'something_unknown'], + ); + check(m2).flags.deepEquals([MessageFlag.read, MessageFlag.unknown]); + }); + }); + group('DmMessage', () { final Map baseJson = Map.unmodifiable( eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]).toJson()); diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 6dfca6e4de..9c1160bbb1 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -269,7 +269,7 @@ void main() async { id: 1, messageId: originalMessage.id, messageIds: [originalMessage.id], - flags: ["starred"], + flags: [MessageFlag.starred], renderedContent: "

Hello, edited

", editTimestamp: 99999, isMeMessage: true, diff --git a/test/model/narrow_test.dart b/test/model/narrow_test.dart index be15e3adf2..c7825c6766 100644 --- a/test/model/narrow_test.dart +++ b/test/model/narrow_test.dart @@ -69,6 +69,27 @@ void main() { selfUserId: eg.selfUser.userId)); }); + test('withUser: same user', () { + final actual = DmNarrow.withUser(5, selfUserId: 5); + check(actual).equals(DmNarrow( + allRecipientIds: [5], + selfUserId: 5)); + }); + + test('withUser: user ID less than selfUserId', () { + final actual = DmNarrow.withUser(3, selfUserId: 5); + check(actual).equals(DmNarrow( + allRecipientIds: [3, 5], + selfUserId: 5)); + }); + + test('withUser: user ID greater than selfUserId', () { + final actual = DmNarrow.withUser(7, selfUserId: 5); + check(actual).equals(DmNarrow( + allRecipientIds: [5, 7], + selfUserId: 5)); + }); + test('otherRecipientIds', () { check(DmNarrow(allRecipientIds: [1, 2, 3], selfUserId: 2)) .otherRecipientIds.deepEquals([1, 3]); diff --git a/test/stdlib_checks.dart b/test/stdlib_checks.dart index e2d05657e1..2829c92ec5 100644 --- a/test/stdlib_checks.dart +++ b/test/stdlib_checks.dart @@ -57,7 +57,7 @@ Object? deepToJson(Object? object) { case List(): result = object.map((x) => deepToJson(x)).toList(); case Map() when object.keys.every((k) => k is String): - result = object.map((k, v) => MapEntry(k, deepToJson(v))); + result = object.map((k, v) => MapEntry(k, deepToJson(v))); default: return (null, false); }