From 3ba74eab9c8bbca27a8f482f2dcfb37c790cf8c5 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Fri, 10 Nov 2023 12:58:19 +0000 Subject: [PATCH 1/6] api: Change streamPostPolicy to enum --- lib/api/model/model.dart | 29 +++++++++++++++++++++++++---- lib/api/model/model.g.dart | 14 ++++++++++++-- test/example_data.dart | 4 ++-- 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 77a9581eea..e230d211b4 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -249,8 +249,8 @@ class ZulipStream { final bool historyPublicToSubscribers; final int? messageRetentionDays; - final int streamPostPolicy; // TODO enum - // final bool isAnnouncementOnly; // deprecated; ignore + final StreamPostPolicy streamPostPolicy; + // final bool isAnnouncementOnly; // deprecated for `streamPostPolicy`; ignore final int? canRemoveSubscribersGroupId; // TODO(server-6) @@ -275,6 +275,27 @@ class ZulipStream { Map toJson() => _$ZulipStreamToJson(this); } +/// Policy for which users can post to the stream. +/// +/// For docs, search for "stream_post_policy" +/// in +@JsonEnum(valueField: 'apiValue') +enum StreamPostPolicy { + any(apiValue: 1), + administrators(apiValue: 2), + fullMembers(apiValue: 3), + moderators(apiValue: 4), + unknown(apiValue: null); + + const StreamPostPolicy({ + required this.apiValue, + }); + + final int? apiValue; + + int? toJson() => apiValue; +} + /// As in `subscriptions` in the initial snapshot. /// /// For docs, search for "subscriptions:" @@ -299,8 +320,8 @@ class Subscription { final int? messageRetentionDays; // final List subscribers; // we register with includeSubscribers false - final int streamPostPolicy; // TODO enum - // final bool? isAnnouncementOnly; // deprecated; ignore + final StreamPostPolicy streamPostPolicy; + // final bool? isAnnouncementOnly; // deprecated for `streamPostPolicy`; ignore final int? canRemoveSubscribersGroupId; // TODO(server-6) diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 5f95103936..2d246d8bbc 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -155,7 +155,8 @@ ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( isWebPublic: json['is_web_public'] as bool, historyPublicToSubscribers: json['history_public_to_subscribers'] as bool, messageRetentionDays: json['message_retention_days'] as int?, - streamPostPolicy: json['stream_post_policy'] as int, + streamPostPolicy: + $enumDecode(_$StreamPostPolicyEnumMap, json['stream_post_policy']), canRemoveSubscribersGroupId: json['can_remove_subscribers_group_id'] as int?, ); @@ -176,6 +177,14 @@ Map _$ZulipStreamToJson(ZulipStream instance) => 'can_remove_subscribers_group_id': instance.canRemoveSubscribersGroupId, }; +const _$StreamPostPolicyEnumMap = { + StreamPostPolicy.any: 1, + StreamPostPolicy.administrators: 2, + StreamPostPolicy.fullMembers: 3, + StreamPostPolicy.moderators: 4, + StreamPostPolicy.unknown: null, +}; + Subscription _$SubscriptionFromJson(Map json) => Subscription( streamId: json['stream_id'] as int, name: json['name'] as String, @@ -192,7 +201,8 @@ Subscription _$SubscriptionFromJson(Map json) => Subscription( isMuted: json['is_muted'] as bool, isWebPublic: json['is_web_public'] as bool?, color: json['color'] as String, - streamPostPolicy: json['stream_post_policy'] as int, + streamPostPolicy: + $enumDecode(_$StreamPostPolicyEnumMap, json['stream_post_policy']), messageRetentionDays: json['message_retention_days'] as int?, historyPublicToSubscribers: json['history_public_to_subscribers'] as bool, firstMessageId: json['first_message_id'] as int?, diff --git a/test/example_data.dart b/test/example_data.dart index 43116a3fa3..9326a838ec 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -103,7 +103,7 @@ ZulipStream stream({ bool? isWebPublic, bool? historyPublicToSubscribers, int? messageRetentionDays, - int? streamPostPolicy, + StreamPostPolicy? streamPostPolicy, int? canRemoveSubscribersGroupId, }) { return ZulipStream( @@ -117,7 +117,7 @@ ZulipStream stream({ isWebPublic: isWebPublic ?? false, historyPublicToSubscribers: historyPublicToSubscribers ?? true, messageRetentionDays: messageRetentionDays, - streamPostPolicy: streamPostPolicy ?? 1, + streamPostPolicy: streamPostPolicy ?? StreamPostPolicy.any, canRemoveSubscribersGroupId: canRemoveSubscribersGroupId ?? 123, ); } From a2181326c801c8fb7900ca63452de4517a7a54f9 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 15 Nov 2023 10:41:00 +0000 Subject: [PATCH 2/6] api: Update ZulipStream for API changes Add `streamWeeklyTraffic` and handle rename of `canRemoveSubscribersGroupId`. This rename also affects `Subscription` but will happen in the next commit. --- lib/api/model/model.dart | 15 +++++++++++-- lib/api/model/model.g.dart | 8 ++++--- test/api/model/model_checks.dart | 4 ++++ test/api/model/model_test.dart | 37 ++++++++++++++++++++++++++++++++ test/example_data.dart | 6 ++++-- 5 files changed, 63 insertions(+), 7 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index e230d211b4..3ee059673a 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -252,7 +252,17 @@ class ZulipStream { final StreamPostPolicy streamPostPolicy; // final bool isAnnouncementOnly; // deprecated for `streamPostPolicy`; ignore - final int? canRemoveSubscribersGroupId; // TODO(server-6) + // TODO(server-6): `canRemoveSubscribersGroupId` added in FL 142 + // TODO(server-8): in FL 197 renamed to `canRemoveSubscribersGroup` + @JsonKey(readValue: _readCanRemoveSubscribersGroup) + final int? canRemoveSubscribersGroup; + + // TODO(server-8): added in FL 199, was previously only on [Subscription] objects + final int? streamWeeklyTraffic; + + static int? _readCanRemoveSubscribersGroup(Map json, String key) { + return json[key] ?? json['can_remove_subscribers_group_id']; + } ZulipStream({ required this.streamId, @@ -266,7 +276,8 @@ class ZulipStream { required this.historyPublicToSubscribers, required this.messageRetentionDays, required this.streamPostPolicy, - required this.canRemoveSubscribersGroupId, + required this.canRemoveSubscribersGroup, + required this.streamWeeklyTraffic, }); factory ZulipStream.fromJson(Map json) => diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 2d246d8bbc..3755247996 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -157,8 +157,9 @@ ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( messageRetentionDays: json['message_retention_days'] as int?, streamPostPolicy: $enumDecode(_$StreamPostPolicyEnumMap, json['stream_post_policy']), - canRemoveSubscribersGroupId: - json['can_remove_subscribers_group_id'] as int?, + canRemoveSubscribersGroup: ZulipStream._readCanRemoveSubscribersGroup( + json, 'can_remove_subscribers_group') as int?, + streamWeeklyTraffic: json['stream_weekly_traffic'] as int?, ); Map _$ZulipStreamToJson(ZulipStream instance) => @@ -174,7 +175,8 @@ Map _$ZulipStreamToJson(ZulipStream instance) => 'history_public_to_subscribers': instance.historyPublicToSubscribers, 'message_retention_days': instance.messageRetentionDays, 'stream_post_policy': instance.streamPostPolicy, - 'can_remove_subscribers_group_id': instance.canRemoveSubscribersGroupId, + 'can_remove_subscribers_group': instance.canRemoveSubscribersGroup, + 'stream_weekly_traffic': instance.streamWeeklyTraffic, }; const _$StreamPostPolicyEnumMap = { diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index a6d4c4a2eb..603712a9f4 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -1,6 +1,10 @@ import 'package:checks/checks.dart'; import 'package:zulip/api/model/model.dart'; +extension ZulipStreamChecks on Subject { + Subject get canRemoveSubscribersGroup => has((e) => e.canRemoveSubscribersGroup, 'canRemoveSubscribersGroup'); +} + extension MessageChecks on Subject { Subject get content => has((e) => e.content, 'content'); Subject get isMeMessage => has((e) => e.isMeMessage, 'isMeMessage'); diff --git a/test/api/model/model_test.dart b/test/api/model/model_test.dart index 90d6177e5a..1bacb090a2 100644 --- a/test/api/model/model_test.dart +++ b/test/api/model/model_test.dart @@ -65,6 +65,43 @@ void main() { }); }); + group('ZulipStream.canRemoveSubscribersGroup', () { + final Map baseJson = Map.unmodifiable({ + 'stream_id': 123, + 'name': 'A stream', + 'description': 'A description', + 'rendered_description': '

A description

', + 'date_created': 1686774898, + 'first_message_id': null, + 'invite_only': false, + 'is_web_public': false, + 'history_public_to_subscribers': true, + 'message_retention_days': null, + 'stream_post_policy': StreamPostPolicy.any.apiValue, + // 'can_remove_subscribers_group': null, + 'stream_weekly_traffic': null, + }); + + test('smoke', () { + check(ZulipStream.fromJson({ ...baseJson, + 'can_remove_subscribers_group': 123, + })).canRemoveSubscribersGroup.equals(123); + }); + + // TODO(server-8): field renamed in FL 197 + test('support old can_remove_subscribers_group_id', () { + check(ZulipStream.fromJson({ ...baseJson, + 'can_remove_subscribers_group_id': 456, + })).canRemoveSubscribersGroup.equals(456); + }); + + // TODO(server-6): field added in FL 142 + test('support field missing', () { + check(ZulipStream.fromJson({ ...baseJson, + })).canRemoveSubscribersGroup.isNull(); + }); + }); + group('Message', () { test('no crash on unrecognized flag', () { final m1 = Message.fromJson( diff --git a/test/example_data.dart b/test/example_data.dart index 9326a838ec..612071c88b 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -104,7 +104,8 @@ ZulipStream stream({ bool? historyPublicToSubscribers, int? messageRetentionDays, StreamPostPolicy? streamPostPolicy, - int? canRemoveSubscribersGroupId, + int? canRemoveSubscribersGroup, + int? streamWeeklyTraffic, }) { return ZulipStream( streamId: streamId ?? 123, // TODO generate example IDs @@ -118,7 +119,8 @@ ZulipStream stream({ historyPublicToSubscribers: historyPublicToSubscribers ?? true, messageRetentionDays: messageRetentionDays, streamPostPolicy: streamPostPolicy ?? StreamPostPolicy.any, - canRemoveSubscribersGroupId: canRemoveSubscribersGroupId ?? 123, + canRemoveSubscribersGroup: canRemoveSubscribersGroup ?? 123, + streamWeeklyTraffic: streamWeeklyTraffic, ); } const _stream = stream; From f0d11a9425c6c94d072917af3a7b690f093b7bf5 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 15 Nov 2023 12:43:15 +0000 Subject: [PATCH 3/6] api: Update Subscription for API changes Complete rename of `canRemoveSubscribersGroupId` that was done in `ZulipStream` in previous commit. Also change `isWebPublic` to be non-null. It has been a field on subscriptions since at least FL 8 (see zulip/zulip@f93c19ec6 in `zerver/openapi/zulip.yaml` and the docs attached to `Subscription.API_FIELDS` in `zerver/models.py`). --- lib/api/model/model.dart | 13 ++++++++++--- lib/api/model/model.g.dart | 8 ++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 3ee059673a..4c1e78a4e1 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -326,7 +326,7 @@ class Subscription { final int? streamWeeklyTraffic; final bool inviteOnly; - final bool? isWebPublic; // TODO(server-??): doc doesn't say when added + final bool isWebPublic; final bool historyPublicToSubscribers; final int? messageRetentionDays; // final List subscribers; // we register with includeSubscribers false @@ -334,7 +334,10 @@ class Subscription { final StreamPostPolicy streamPostPolicy; // final bool? isAnnouncementOnly; // deprecated for `streamPostPolicy`; ignore - final int? canRemoveSubscribersGroupId; // TODO(server-6) + // TODO(server-6): `canRemoveSubscribersGroupId` added in FL 142 + // TODO(server-8): in FL 197 renamed to `canRemoveSubscribersGroup` + @JsonKey(readValue: _readCanRemoveSubscribersGroup) + final int? canRemoveSubscribersGroup; // Then, fields that are specific to the subscription, // i.e. the user's relationship to the stream. @@ -352,6 +355,10 @@ class Subscription { final String color; + static int? _readCanRemoveSubscribersGroup(Map json, String key) { + return json[key] ?? json['can_remove_subscribers_group_id']; + } + Subscription({ required this.streamId, required this.name, @@ -373,7 +380,7 @@ class Subscription { required this.historyPublicToSubscribers, required this.firstMessageId, required this.streamWeeklyTraffic, - required this.canRemoveSubscribersGroupId, + required this.canRemoveSubscribersGroup, }); factory Subscription.fromJson(Map json) => diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 3755247996..1cd0b0e5be 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -201,7 +201,7 @@ Subscription _$SubscriptionFromJson(Map json) => Subscription( audibleNotifications: json['audible_notifications'] as bool?, pinToTop: json['pin_to_top'] as bool, isMuted: json['is_muted'] as bool, - isWebPublic: json['is_web_public'] as bool?, + isWebPublic: json['is_web_public'] as bool, color: json['color'] as String, streamPostPolicy: $enumDecode(_$StreamPostPolicyEnumMap, json['stream_post_policy']), @@ -209,8 +209,8 @@ Subscription _$SubscriptionFromJson(Map json) => Subscription( historyPublicToSubscribers: json['history_public_to_subscribers'] as bool, firstMessageId: json['first_message_id'] as int?, streamWeeklyTraffic: json['stream_weekly_traffic'] as int?, - canRemoveSubscribersGroupId: - json['can_remove_subscribers_group_id'] as int?, + canRemoveSubscribersGroup: Subscription._readCanRemoveSubscribersGroup( + json, 'can_remove_subscribers_group') as int?, ); Map _$SubscriptionToJson(Subscription instance) => @@ -227,7 +227,7 @@ Map _$SubscriptionToJson(Subscription instance) => 'history_public_to_subscribers': instance.historyPublicToSubscribers, 'message_retention_days': instance.messageRetentionDays, 'stream_post_policy': instance.streamPostPolicy, - 'can_remove_subscribers_group_id': instance.canRemoveSubscribersGroupId, + 'can_remove_subscribers_group': instance.canRemoveSubscribersGroup, 'desktop_notifications': instance.desktopNotifications, 'email_notifications': instance.emailNotifications, 'wildcard_mentions_notify': instance.wildcardMentionsNotify, From 89f3b785c7cc529abe47acfd4a408921050989b1 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 15 Nov 2023 10:45:24 +0000 Subject: [PATCH 4/6] api [nfc]: Change Subscription to extend from ZulipStream --- lib/api/model/model.dart | 61 ++++++++++---------------------------- lib/api/model/model.g.dart | 20 ++++++------- 2 files changed, 25 insertions(+), 56 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 4c1e78a4e1..182daad19f 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -312,36 +312,9 @@ enum StreamPostPolicy { /// For docs, search for "subscriptions:" /// in . @JsonSerializable(fieldRename: FieldRename.snake) -class Subscription { - // First, fields that are about the stream and not the user's relation to it. - // These are largely the same as in [ZulipStream]. - - final int streamId; - final String name; - final String description; - final String renderedDescription; - - final int dateCreated; - final int? firstMessageId; - final int? streamWeeklyTraffic; - - final bool inviteOnly; - final bool isWebPublic; - final bool historyPublicToSubscribers; - final int? messageRetentionDays; +class Subscription extends ZulipStream { // final List subscribers; // we register with includeSubscribers false - final StreamPostPolicy streamPostPolicy; - // final bool? isAnnouncementOnly; // deprecated for `streamPostPolicy`; ignore - - // TODO(server-6): `canRemoveSubscribersGroupId` added in FL 142 - // TODO(server-8): in FL 197 renamed to `canRemoveSubscribersGroup` - @JsonKey(readValue: _readCanRemoveSubscribersGroup) - final int? canRemoveSubscribersGroup; - - // Then, fields that are specific to the subscription, - // i.e. the user's relationship to the stream. - final bool? desktopNotifications; final bool? emailNotifications; final bool? wildcardMentionsNotify; @@ -349,23 +322,25 @@ class Subscription { final bool? audibleNotifications; final bool pinToTop; - final bool isMuted; // final bool? inHomeView; // deprecated; ignore final String color; - static int? _readCanRemoveSubscribersGroup(Map json, String key) { - return json[key] ?? json['can_remove_subscribers_group_id']; - } - Subscription({ - required this.streamId, - required this.name, - required this.description, - required this.renderedDescription, - required this.dateCreated, - required this.inviteOnly, + required super.streamId, + required super.name, + required super.description, + required super.renderedDescription, + required super.dateCreated, + required super.firstMessageId, + required super.inviteOnly, + required super.isWebPublic, + required super.historyPublicToSubscribers, + required super.messageRetentionDays, + required super.streamPostPolicy, + required super.canRemoveSubscribersGroup, + required super.streamWeeklyTraffic, required this.desktopNotifications, required this.emailNotifications, required this.wildcardMentionsNotify, @@ -373,19 +348,13 @@ class Subscription { required this.audibleNotifications, required this.pinToTop, required this.isMuted, - required this.isWebPublic, required this.color, - required this.streamPostPolicy, - required this.messageRetentionDays, - required this.historyPublicToSubscribers, - required this.firstMessageId, - required this.streamWeeklyTraffic, - required this.canRemoveSubscribersGroup, }); factory Subscription.fromJson(Map json) => _$SubscriptionFromJson(json); + @override Map toJson() => _$SubscriptionToJson(this); } diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 1cd0b0e5be..ecc8b11d48 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -193,7 +193,16 @@ Subscription _$SubscriptionFromJson(Map json) => Subscription( description: json['description'] as String, renderedDescription: json['rendered_description'] as String, dateCreated: json['date_created'] as int, + firstMessageId: json['first_message_id'] as int?, inviteOnly: json['invite_only'] as bool, + isWebPublic: json['is_web_public'] as bool, + historyPublicToSubscribers: json['history_public_to_subscribers'] as bool, + messageRetentionDays: json['message_retention_days'] as int?, + streamPostPolicy: + $enumDecode(_$StreamPostPolicyEnumMap, json['stream_post_policy']), + canRemoveSubscribersGroup: ZulipStream._readCanRemoveSubscribersGroup( + json, 'can_remove_subscribers_group') as int?, + streamWeeklyTraffic: json['stream_weekly_traffic'] as int?, desktopNotifications: json['desktop_notifications'] as bool?, emailNotifications: json['email_notifications'] as bool?, wildcardMentionsNotify: json['wildcard_mentions_notify'] as bool?, @@ -201,16 +210,7 @@ Subscription _$SubscriptionFromJson(Map json) => Subscription( audibleNotifications: json['audible_notifications'] as bool?, pinToTop: json['pin_to_top'] as bool, isMuted: json['is_muted'] as bool, - isWebPublic: json['is_web_public'] as bool, color: json['color'] as String, - streamPostPolicy: - $enumDecode(_$StreamPostPolicyEnumMap, json['stream_post_policy']), - messageRetentionDays: json['message_retention_days'] as int?, - historyPublicToSubscribers: json['history_public_to_subscribers'] as bool, - firstMessageId: json['first_message_id'] as int?, - streamWeeklyTraffic: json['stream_weekly_traffic'] as int?, - canRemoveSubscribersGroup: Subscription._readCanRemoveSubscribersGroup( - json, 'can_remove_subscribers_group') as int?, ); Map _$SubscriptionToJson(Subscription instance) => @@ -221,13 +221,13 @@ Map _$SubscriptionToJson(Subscription instance) => 'rendered_description': instance.renderedDescription, 'date_created': instance.dateCreated, 'first_message_id': instance.firstMessageId, - 'stream_weekly_traffic': instance.streamWeeklyTraffic, 'invite_only': instance.inviteOnly, 'is_web_public': instance.isWebPublic, 'history_public_to_subscribers': instance.historyPublicToSubscribers, 'message_retention_days': instance.messageRetentionDays, 'stream_post_policy': instance.streamPostPolicy, 'can_remove_subscribers_group': instance.canRemoveSubscribersGroup, + 'stream_weekly_traffic': instance.streamWeeklyTraffic, 'desktop_notifications': instance.desktopNotifications, 'email_notifications': instance.emailNotifications, 'wildcard_mentions_notify': instance.wildcardMentionsNotify, From c717f3d308a5d4af580f936a1580e66634e34ce3 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 15 Nov 2023 10:52:21 +0000 Subject: [PATCH 5/6] api: Make Subscription properties mutable --- lib/api/model/model.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 182daad19f..23edbb100a 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -315,17 +315,17 @@ enum StreamPostPolicy { class Subscription extends ZulipStream { // final List subscribers; // we register with includeSubscribers false - final bool? desktopNotifications; - final bool? emailNotifications; - final bool? wildcardMentionsNotify; - final bool? pushNotifications; - final bool? audibleNotifications; - - final bool pinToTop; - final bool isMuted; + bool? desktopNotifications; + bool? emailNotifications; + bool? wildcardMentionsNotify; + bool? pushNotifications; + bool? audibleNotifications; + + bool pinToTop; + bool isMuted; // final bool? inHomeView; // deprecated; ignore - final String color; + String color; Subscription({ required super.streamId, From 3686ca2a16133667a8277f64533342fbf406706d Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Fri, 10 Nov 2023 12:59:08 +0000 Subject: [PATCH 6/6] api: Add subscription events Add events for subscription with `op` values of `add`, `remove`, and `update`. `peer_add` and `peer_remove` left for #374. --- lib/api/model/events.dart | 188 ++++++++++++++++++++++++++++++ lib/api/model/events.g.dart | 110 +++++++++++++++++ lib/model/store.dart | 46 ++++++++ test/api/model/events_checks.dart | 4 + test/api/model/events_test.dart | 12 ++ test/example_data.dart | 40 +++++++ test/model/store_test.dart | 47 ++++++++ 7 files changed, 447 insertions(+) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 4e2723eb59..dbaf96b8a4 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -30,6 +30,15 @@ sealed class Event { case 'update': return RealmUserUpdateEvent.fromJson(json); default: return UnexpectedEvent.fromJson(json); } + case 'subscription': + switch (json['op'] as String) { + case 'add': return SubscriptionAddEvent.fromJson(json); + case 'remove': return SubscriptionRemoveEvent.fromJson(json); + case 'update': return SubscriptionUpdateEvent.fromJson(json); + case 'peer_add': return SubscriptionPeerAddEvent.fromJson(json); + case 'peer_remove': return SubscriptionPeerRemoveEvent.fromJson(json); + default: return UnexpectedEvent.fromJson(json); + } case 'stream': switch (json['op'] as String) { case 'create': return StreamCreateEvent.fromJson(json); @@ -272,6 +281,185 @@ class RealmUserUpdateEvent extends RealmUserEvent { Map toJson() => _$RealmUserUpdateEventToJson(this); } +/// A Zulip event of type `subscription`. +/// +/// The corresponding API docs are in several places for +/// different values of `op`; see subclasses. +sealed class SubscriptionEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'subscription'; + + String get op; + + SubscriptionEvent({required super.id}); +} + +/// A [SubscriptionEvent] with op `add`: https://zulip.com/api/get-events#subscription-add +@JsonSerializable(fieldRename: FieldRename.snake) +class SubscriptionAddEvent extends SubscriptionEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'add'; + + final List subscriptions; + + SubscriptionAddEvent({required super.id, required this.subscriptions}); + + factory SubscriptionAddEvent.fromJson(Map json) => + _$SubscriptionAddEventFromJson(json); + + @override + Map toJson() => _$SubscriptionAddEventToJson(this); +} + +/// A [SubscriptionEvent] with op `remove`: https://zulip.com/api/get-events#subscription-remove +@JsonSerializable(fieldRename: FieldRename.snake) +class SubscriptionRemoveEvent extends SubscriptionEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'remove'; + + @JsonKey(readValue: _readStreamIds) + final List streamIds; + + static List _readStreamIds(Map json, String key) { + return (json['subscriptions'] as List) + .map((e) => (e as Map)['stream_id'] as int) + .toList(); + } + + SubscriptionRemoveEvent({required super.id, required this.streamIds}); + + factory SubscriptionRemoveEvent.fromJson(Map json) => + _$SubscriptionRemoveEventFromJson(json); + + @override + Map toJson() => _$SubscriptionRemoveEventToJson(this); +} + +/// A [SubscriptionEvent] with op `update`: https://zulip.com/api/get-events#subscription-update +@JsonSerializable(fieldRename: FieldRename.snake) +class SubscriptionUpdateEvent extends SubscriptionEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'update'; + + final int streamId; + + final SubscriptionProperty property; + + /// The new value, or null if we don't recognize the setting. + /// + /// This will have the type appropriate for [property]; for example, + /// if the setting is boolean, then `value is bool` will always be true. + /// This invariant is enforced by [SubscriptionUpdateEvent.fromJson]. + @JsonKey(readValue: _readValue) + final Object? value; + + /// [value], with a check that its type corresponds to [property] + /// (e.g., `value as bool`). + static Object? _readValue(Map json, String key) { + final value = json['value']; + switch (SubscriptionProperty.fromRawString(json['property'] as String)) { + case SubscriptionProperty.color: + return value as String; + case SubscriptionProperty.isMuted: + case SubscriptionProperty.inHomeView: + case SubscriptionProperty.pinToTop: + case SubscriptionProperty.desktopNotifications: + case SubscriptionProperty.audibleNotifications: + case SubscriptionProperty.pushNotifications: + case SubscriptionProperty.emailNotifications: + case SubscriptionProperty.wildcardMentionsNotify: + return value as bool; + case SubscriptionProperty.unknown: + return null; + } + } + + SubscriptionUpdateEvent({ + required super.id, + required this.streamId, + required this.property, + required this.value, + }); + + factory SubscriptionUpdateEvent.fromJson(Map json) => + _$SubscriptionUpdateEventFromJson(json); + + @override + Map toJson() => _$SubscriptionUpdateEventToJson(this); +} + +/// The name of a property in [Subscription]. +/// +/// Used in handling of [SubscriptionUpdateEvent]. +@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) +enum SubscriptionProperty { + color, + isMuted, + inHomeView, + pinToTop, + desktopNotifications, + audibleNotifications, + pushNotifications, + emailNotifications, + wildcardMentionsNotify, + unknown; + + static SubscriptionProperty fromRawString(String raw) => _byRawString[raw] ?? unknown; + + static final _byRawString = _$SubscriptionPropertyEnumMap + .map((key, value) => MapEntry(value, key)); +} + +/// A [SubscriptionEvent] with op `peer_add`: https://zulip.com/api/get-events#subscription-peer_add +@JsonSerializable(fieldRename: FieldRename.snake) +class SubscriptionPeerAddEvent extends SubscriptionEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'peer_add'; + + List streamIds; + List userIds; + + SubscriptionPeerAddEvent({ + required super.id, + required this.streamIds, + required this.userIds, + }); + + factory SubscriptionPeerAddEvent.fromJson(Map json) => + _$SubscriptionPeerAddEventFromJson(json); + + @override + Map toJson() => _$SubscriptionPeerAddEventToJson(this); +} + +/// A [SubscriptionEvent] with op `peer_remove`: https://zulip.com/api/get-events#subscription-peer_remove +@JsonSerializable(fieldRename: FieldRename.snake) +class SubscriptionPeerRemoveEvent extends SubscriptionEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'peer_remove'; + + List streamIds; + List userIds; + + SubscriptionPeerRemoveEvent({ + required super.id, + required this.streamIds, + required this.userIds, + }); + + factory SubscriptionPeerRemoveEvent.fromJson(Map json) => + _$SubscriptionPeerRemoveEventFromJson(json); + + @override + Map toJson() => _$SubscriptionPeerRemoveEventToJson(this); +} + /// A Zulip event of type `stream`. /// /// The corresponding API docs are in several places for diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index b5c20aafd9..95b2670adc 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -156,6 +156,116 @@ const _$UserRoleEnumMap = { UserRole.unknown: null, }; +SubscriptionAddEvent _$SubscriptionAddEventFromJson( + Map json) => + SubscriptionAddEvent( + id: json['id'] as int, + subscriptions: (json['subscriptions'] as List) + .map((e) => Subscription.fromJson(e as Map)) + .toList(), + ); + +Map _$SubscriptionAddEventToJson( + SubscriptionAddEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'subscriptions': instance.subscriptions, + }; + +SubscriptionRemoveEvent _$SubscriptionRemoveEventFromJson( + Map json) => + SubscriptionRemoveEvent( + id: json['id'] as int, + streamIds: (SubscriptionRemoveEvent._readStreamIds(json, 'stream_ids') + as List) + .map((e) => e as int) + .toList(), + ); + +Map _$SubscriptionRemoveEventToJson( + SubscriptionRemoveEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'stream_ids': instance.streamIds, + }; + +SubscriptionUpdateEvent _$SubscriptionUpdateEventFromJson( + Map json) => + SubscriptionUpdateEvent( + id: json['id'] as int, + streamId: json['stream_id'] as int, + property: $enumDecode(_$SubscriptionPropertyEnumMap, json['property']), + value: SubscriptionUpdateEvent._readValue(json, 'value'), + ); + +Map _$SubscriptionUpdateEventToJson( + SubscriptionUpdateEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'stream_id': instance.streamId, + 'property': _$SubscriptionPropertyEnumMap[instance.property]!, + 'value': instance.value, + }; + +const _$SubscriptionPropertyEnumMap = { + SubscriptionProperty.color: 'color', + SubscriptionProperty.isMuted: 'is_muted', + SubscriptionProperty.inHomeView: 'in_home_view', + SubscriptionProperty.pinToTop: 'pin_to_top', + SubscriptionProperty.desktopNotifications: 'desktop_notifications', + SubscriptionProperty.audibleNotifications: 'audible_notifications', + SubscriptionProperty.pushNotifications: 'push_notifications', + SubscriptionProperty.emailNotifications: 'email_notifications', + SubscriptionProperty.wildcardMentionsNotify: 'wildcard_mentions_notify', + SubscriptionProperty.unknown: 'unknown', +}; + +SubscriptionPeerAddEvent _$SubscriptionPeerAddEventFromJson( + Map json) => + SubscriptionPeerAddEvent( + id: json['id'] as int, + streamIds: + (json['stream_ids'] as List).map((e) => e as int).toList(), + userIds: + (json['user_ids'] as List).map((e) => e as int).toList(), + ); + +Map _$SubscriptionPeerAddEventToJson( + SubscriptionPeerAddEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'stream_ids': instance.streamIds, + 'user_ids': instance.userIds, + }; + +SubscriptionPeerRemoveEvent _$SubscriptionPeerRemoveEventFromJson( + Map json) => + SubscriptionPeerRemoveEvent( + id: json['id'] as int, + streamIds: + (json['stream_ids'] as List).map((e) => e as int).toList(), + userIds: + (json['user_ids'] as List).map((e) => e as int).toList(), + ); + +Map _$SubscriptionPeerRemoveEventToJson( + SubscriptionPeerRemoveEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'stream_ids': instance.streamIds, + 'user_ids': instance.userIds, + }; + StreamCreateEvent _$StreamCreateEventFromJson(Map json) => StreamCreateEvent( id: json['id'] as int, diff --git a/lib/model/store.dart b/lib/model/store.dart index 655fa64310..fc8d164808 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -305,6 +305,52 @@ class PerAccountStore extends ChangeNotifier { subscriptions.remove(stream.streamId); } notifyListeners(); + } else if (event is SubscriptionAddEvent) { + assert(debugLog("server event: subscription/add")); + for (final subscription in event.subscriptions) { + subscriptions[subscription.streamId] = subscription; + } + notifyListeners(); + } else if (event is SubscriptionRemoveEvent) { + assert(debugLog("server event: subscription/remove")); + for (final streamId in event.streamIds) { + subscriptions.remove(streamId); + } + notifyListeners(); + } else if (event is SubscriptionUpdateEvent) { + assert(debugLog("server event: subscription/update")); + final subscription = subscriptions[event.streamId]; + if (subscription == null) return; // TODO(log) + switch (event.property) { + case SubscriptionProperty.color: + subscription.color = event.value as String; + case SubscriptionProperty.isMuted: + subscription.isMuted = event.value as bool; + case SubscriptionProperty.inHomeView: + subscription.isMuted = !(event.value as bool); + case SubscriptionProperty.pinToTop: + subscription.pinToTop = event.value as bool; + case SubscriptionProperty.desktopNotifications: + subscription.desktopNotifications = event.value as bool; + case SubscriptionProperty.audibleNotifications: + subscription.audibleNotifications = event.value as bool; + case SubscriptionProperty.pushNotifications: + subscription.pushNotifications = event.value as bool; + case SubscriptionProperty.emailNotifications: + subscription.emailNotifications = event.value as bool; + case SubscriptionProperty.wildcardMentionsNotify: + subscription.wildcardMentionsNotify = event.value as bool; + case SubscriptionProperty.unknown: + // unrecognized property; do nothing + return; + } + notifyListeners(); + } else if (event is SubscriptionPeerAddEvent) { + assert(debugLog("server event: subscription/peer_add")); + // TODO(#374): handle event + } else if (event is SubscriptionPeerRemoveEvent) { + assert(debugLog("server event: subscription/peer_remove")); + // TODO(#374): handle event } else if (event is MessageEvent) { assert(debugLog("server event: message ${jsonEncode(event.message.toJson())}")); recentDmConversationsView.handleMessageEvent(event); diff --git a/test/api/model/events_checks.dart b/test/api/model/events_checks.dart index a7e9440916..038abadb0a 100644 --- a/test/api/model/events_checks.dart +++ b/test/api/model/events_checks.dart @@ -15,6 +15,10 @@ extension AlertWordsEventChecks on Subject { Subject> get alertWords => has((e) => e.alertWords, 'alertWords'); } +extension SubscriptionRemoveEventChecks on Subject { + Subject> get streamIds => has((e) => e.streamIds, 'streamIds'); +} + extension MessageEventChecks on Subject { Subject get message => has((e) => e.message, 'message'); } diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index 2fe7153065..dd2412bd84 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -30,6 +30,18 @@ void main() { ).isEmpty(); }); + test('subscription/remove: deserialize stream_ids correctly', () { + check(Event.fromJson({ + 'id': 1, + 'type': 'subscription', + 'op': 'remove', + 'subscriptions': [ + {'stream_id': 123, 'name': 'name 1'}, + {'stream_id': 456, 'name': 'name 2'}, + ], + }) as SubscriptionRemoveEvent).streamIds.jsonEquals([123, 456]); + }); + test('message: move flags into message object', () { final message = eg.streamMessage(); MessageEvent mkEvent(List flags) => Event.fromJson({ diff --git a/test/example_data.dart b/test/example_data.dart index 612071c88b..9b31124609 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -125,6 +125,46 @@ ZulipStream stream({ } const _stream = stream; +/// Construct an example subscription from a stream. +/// +/// We only allow overrides of values specific to the [Subscription], all +/// other properties are copied from the [ZulipStream] provided. +Subscription subscription( + ZulipStream stream, { + bool? desktopNotifications, + bool? emailNotifications, + bool? wildcardMentionsNotify, + bool? pushNotifications, + bool? audibleNotifications, + bool? pinToTop, + bool? isMuted, + String? color, +}) { + return Subscription( + streamId: stream.streamId, + name: stream.name, + description: stream.description, + renderedDescription: stream.renderedDescription, + dateCreated: stream.dateCreated, + firstMessageId: stream.firstMessageId, + inviteOnly: stream.inviteOnly, + isWebPublic: stream.isWebPublic, + historyPublicToSubscribers: stream.historyPublicToSubscribers, + messageRetentionDays: stream.messageRetentionDays, + streamPostPolicy: stream.streamPostPolicy, + canRemoveSubscribersGroup: stream.canRemoveSubscribersGroup, + streamWeeklyTraffic: stream.streamWeeklyTraffic, + desktopNotifications: desktopNotifications ?? false, + emailNotifications: emailNotifications ?? false, + wildcardMentionsNotify: wildcardMentionsNotify ?? false, + pushNotifications: pushNotifications ?? false, + audibleNotifications: audibleNotifications ?? false, + pinToTop: pinToTop ?? false, + isMuted: isMuted ?? false, + color: color ?? "#FF0000", + ); +} + //////////////////////////////////////////////////////////////// // Messages, and pieces of messages. // diff --git a/test/model/store_test.dart b/test/model/store_test.dart index b4c4390fec..f1f53714a8 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:checks/checks.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/notifications.dart'; @@ -178,6 +179,52 @@ void main() { checkLastRequest(token: '456def'); }); }); + + group('handleEvent for SubscriptionEvent', () { + final stream = eg.stream(); + + test('SubscriptionProperty.color updates with a string value', () { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + streams: [stream], + subscriptions: [eg.subscription(stream, color: "#FF0000")], + )); + check(store.subscriptions[stream.streamId]!.color).equals('#FF0000'); + + store.handleEvent(SubscriptionUpdateEvent(id: 1, + streamId: stream.streamId, + property: SubscriptionProperty.color, + value: "#FF00FF")); + check(store.subscriptions[stream.streamId]!.color).equals('#FF00FF'); + }); + + test('SubscriptionProperty.isMuted updates with a boolean value', () { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + streams: [stream], + subscriptions: [eg.subscription(stream, isMuted: false)], + )); + check(store.subscriptions[stream.streamId]!.isMuted).isFalse(); + + store.handleEvent(SubscriptionUpdateEvent(id: 1, + streamId: stream.streamId, + property: SubscriptionProperty.isMuted, + value: true)); + check(store.subscriptions[stream.streamId]!.isMuted).isTrue(); + }); + + test('SubscriptionProperty.inHomeView updates isMuted instead', () { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + streams: [stream], + subscriptions: [eg.subscription(stream, isMuted: false)], + )); + check(store.subscriptions[stream.streamId]!.isMuted).isFalse(); + + store.handleEvent(SubscriptionUpdateEvent(id: 1, + streamId: stream.streamId, + property: SubscriptionProperty.inHomeView, + value: false)); + check(store.subscriptions[stream.streamId]!.isMuted).isTrue(); + }); + }); } class LoadingTestGlobalStore extends TestGlobalStore {