diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 4e2723eb591..dbaf96b8a42 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 b5c20aafd94..95b2670adcf 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/api/model/model.dart b/lib/api/model/model.dart index 13c3d583e1e..0f71e88c6a6 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -322,7 +322,6 @@ class Subscription extends ZulipStream { bool? audibleNotifications; bool pinToTop; - bool isMuted; // final bool? inHomeView; // deprecated; ignore diff --git a/lib/model/store.dart b/lib/model/store.dart index 655fa64310b..fc8d1648085 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 a7e94409163..038abadb0a3 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 2fe7153065b..dd2412bd84a 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 612071c88b2..9b311246095 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 b4c4390feca..f1f53714a8f 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 {