diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 8dc395a8da6..6fac802e571 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -40,7 +40,7 @@ sealed class Event { switch (json['op'] as String) { case 'create': return ChannelCreateEvent.fromJson(json); case 'delete': return ChannelDeleteEvent.fromJson(json); - // TODO(#182): case 'update': … + case 'update': return ChannelUpdateEvent.fromJson(json); default: return UnexpectedEvent.fromJson(json); } case 'subscription': @@ -379,8 +379,73 @@ class ChannelDeleteEvent extends ChannelEvent { Map toJson() => _$ChannelDeleteEventToJson(this); } -// TODO(#182) ChannelUpdateEvent, for a [ChannelEvent] with op `update`: -// https://zulip.com/api/get-events#stream-update +/// A [ChannelEvent] with op `update`: https://zulip.com/api/get-events#stream-update +@JsonSerializable(fieldRename: FieldRename.snake) +class ChannelUpdateEvent extends ChannelEvent { + @override + String get op => 'update'; + + final int streamId; + final String name; + + /// The name of the channel property, or null if we don't recognize it. + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) + final ChannelPropertyName? property; + + /// The new value, or null if we don't recognize the property. + /// + /// This will have the type appropriate for [property]; for example, + /// if the property is boolean, then `value is bool` will always be true. + /// This invariant is enforced by [ChannelUpdateEvent.fromJson]. + @JsonKey(readValue: _readValue) + final Object? value; + + final String? renderedDescription; + final bool? historyPublicToSubscribers; + final bool? isWebPublic; + + ChannelUpdateEvent({ + required super.id, + required this.streamId, + required this.name, + required this.property, + required this.value, + this.renderedDescription, + this.historyPublicToSubscribers, + this.isWebPublic, + }); + + /// [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 (ChannelPropertyName.fromRawString(json['property'] as String)) { + case ChannelPropertyName.name: + case ChannelPropertyName.description: + return value as String; + case ChannelPropertyName.firstMessageId: + return value as int?; + case ChannelPropertyName.inviteOnly: + return value as bool; + case ChannelPropertyName.messageRetentionDays: + return value as int?; + case ChannelPropertyName.channelPostPolicy: + return ChannelPostPolicy.fromInt(value as int?); + case ChannelPropertyName.canRemoveSubscribersGroup: + case ChannelPropertyName.canRemoveSubscribersGroupId: + case ChannelPropertyName.streamWeeklyTraffic: + return value as int?; + case null: + return null; + } + } + + factory ChannelUpdateEvent.fromJson(Map json) => + _$ChannelUpdateEventFromJson(json); + + @override + Map toJson() => _$ChannelUpdateEventToJson(this); +} /// A Zulip event of type `subscription`. /// diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index dc988b45cb2..9226278d268 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -228,6 +228,47 @@ Map _$ChannelDeleteEventToJson(ChannelDeleteEvent instance) => 'streams': instance.streams, }; +ChannelUpdateEvent _$ChannelUpdateEventFromJson(Map json) => + ChannelUpdateEvent( + id: (json['id'] as num).toInt(), + streamId: (json['stream_id'] as num).toInt(), + name: json['name'] as String, + property: $enumDecodeNullable( + _$ChannelPropertyNameEnumMap, json['property'], + unknownValue: JsonKey.nullForUndefinedEnumValue), + value: ChannelUpdateEvent._readValue(json, 'value'), + renderedDescription: json['rendered_description'] as String?, + historyPublicToSubscribers: + json['history_public_to_subscribers'] as bool?, + isWebPublic: json['is_web_public'] as bool?, + ); + +Map _$ChannelUpdateEventToJson(ChannelUpdateEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'stream_id': instance.streamId, + 'name': instance.name, + 'property': _$ChannelPropertyNameEnumMap[instance.property], + 'value': instance.value, + 'rendered_description': instance.renderedDescription, + 'history_public_to_subscribers': instance.historyPublicToSubscribers, + 'is_web_public': instance.isWebPublic, + }; + +const _$ChannelPropertyNameEnumMap = { + ChannelPropertyName.name: 'name', + ChannelPropertyName.description: 'description', + ChannelPropertyName.firstMessageId: 'first_message_id', + ChannelPropertyName.inviteOnly: 'invite_only', + ChannelPropertyName.messageRetentionDays: 'message_retention_days', + ChannelPropertyName.channelPostPolicy: 'stream_post_policy', + ChannelPropertyName.canRemoveSubscribersGroup: 'can_remove_subscribers_group', + ChannelPropertyName.canRemoveSubscribersGroupId: + 'can_remove_subscribers_group_id', + ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic', +}; + SubscriptionAddEvent _$SubscriptionAddEventFromJson( Map json) => SubscriptionAddEvent( diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 9090c8a377d..c93c024852b 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -395,6 +395,45 @@ enum ChannelPostPolicy { .map((key, value) => MapEntry(value, key)); } +/// The name of the [ZulipStream] properties that gets updated through [ChannelUpdateEvent.property]. +/// +/// In Zulip event-handling code (for [ChannelUpdateEvent]), +/// we switch exhaustively on a value of this type +/// to ensure that every property change in [ZulipStream] responds to the event. +/// +/// Fields on [ZulipStream] not present here: +/// streamId, dateCreated +/// Each of those is immutable on any given channel, and there is no +/// [ChannelUpdateEvent] that updates them. +/// +/// Other fields on [ZulipStream] not present here: +/// renderedDescription, historyPublicToSubscribers, isWebPublic +/// Each of those are updated through separate fields of [ChannelUpdateEvent] +/// with the same names. +@JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) +enum ChannelPropertyName { + name, + description, + firstMessageId, + inviteOnly, + messageRetentionDays, + @JsonValue('stream_post_policy') + channelPostPolicy, + canRemoveSubscribersGroup, + canRemoveSubscribersGroupId, // TODO(Zulip-6): remove, replaced by canRemoveSubscribersGroup + streamWeeklyTraffic; + + /// Get a [ChannelPropertyName] from a raw, snake-case string we recognize, else null. + /// + /// Example: + /// 'invite_only' -> ChannelPropertyName.inviteOnly + static ChannelPropertyName? fromRawString(String raw) => _byRawString[raw]; + + // _$…EnumMap is thanks to `alwaysCreate: true` and `fieldRename: FieldRename.snake` + static final _byRawString = _$ChannelPropertyNameEnumMap + .map((key, value) => MapEntry(value, key)); +} + /// As in `subscriptions` in the initial snapshot. /// /// For docs, search for "subscriptions:" diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 0171278e4dc..2c9ac0163c7 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -399,6 +399,19 @@ const _$EmojisetEnumMap = { Emojiset.text: 'text', }; +const _$ChannelPropertyNameEnumMap = { + ChannelPropertyName.name: 'name', + ChannelPropertyName.description: 'description', + ChannelPropertyName.firstMessageId: 'first_message_id', + ChannelPropertyName.inviteOnly: 'invite_only', + ChannelPropertyName.messageRetentionDays: 'message_retention_days', + ChannelPropertyName.channelPostPolicy: 'stream_post_policy', + ChannelPropertyName.canRemoveSubscribersGroup: 'can_remove_subscribers_group', + ChannelPropertyName.canRemoveSubscribersGroupId: + 'can_remove_subscribers_group_id', + ChannelPropertyName.streamWeeklyTraffic: 'stream_weekly_traffic', +}; + const _$MessageFlagEnumMap = { MessageFlag.read: 'read', MessageFlag.starred: 'starred', diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 81dc4123cb5..4a745840e23 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -199,6 +199,51 @@ class ChannelStoreImpl with ChannelStore { streamsByName.remove(stream.name); subscriptions.remove(stream.streamId); } + + case ChannelUpdateEvent(): + final ChannelUpdateEvent(:streamId, name: String streamName) = event; + assert(identical(streams[streamId], streamsByName[streamName])); + assert(subscriptions[streamId] == null + || identical(subscriptions[streamId], streams[streamId])); + + final channel = streams[streamId]; + if (channel == null) return; + + if (event.renderedDescription != null) { + channel.renderedDescription = event.renderedDescription!; + } + if (event.historyPublicToSubscribers != null) { + channel.historyPublicToSubscribers = event.historyPublicToSubscribers!; + } + if (event.isWebPublic != null) { + channel.isWebPublic = event.isWebPublic!; + } + + if (event.property == null) { + // unrecognized property; do nothing + return; + } + switch (event.property!) { + case ChannelPropertyName.name: + channel.name = event.value as String; + streamsByName.remove(streamName); + streamsByName[channel.name] = channel; + case ChannelPropertyName.description: + channel.description = event.value as String; + case ChannelPropertyName.firstMessageId: + channel.firstMessageId = event.value as int?; + case ChannelPropertyName.inviteOnly: + channel.inviteOnly = event.value as bool; + case ChannelPropertyName.messageRetentionDays: + channel.messageRetentionDays = event.value as int?; + case ChannelPropertyName.channelPostPolicy: + channel.channelPostPolicy = event.value as ChannelPostPolicy; + case ChannelPropertyName.canRemoveSubscribersGroup: + case ChannelPropertyName.canRemoveSubscribersGroupId: + channel.canRemoveSubscribersGroup = event.value as int?; + case ChannelPropertyName.streamWeeklyTraffic: + channel.streamWeeklyTraffic = event.value as int?; + } } } diff --git a/test/example_data.dart b/test/example_data.dart index e14b7c437a9..1716869f350 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -655,6 +655,37 @@ ReactionEvent reactionEvent(Reaction reaction, ReactionOp op, int messageId) { ); } +ChannelUpdateEvent channelUpdateEvent( + ZulipStream stream, { + required ChannelPropertyName property, + required Object? value, +}) { + switch (property) { + case ChannelPropertyName.name: + case ChannelPropertyName.description: + assert(value is String); + case ChannelPropertyName.firstMessageId: + assert(value is int?); + case ChannelPropertyName.inviteOnly: + assert(value is bool); + case ChannelPropertyName.messageRetentionDays: + assert(value is int?); + case ChannelPropertyName.channelPostPolicy: + assert(value is ChannelPostPolicy); + case ChannelPropertyName.canRemoveSubscribersGroup: + case ChannelPropertyName.canRemoveSubscribersGroupId: + case ChannelPropertyName.streamWeeklyTraffic: + assert(value is int?); + } + return ChannelUpdateEvent( + id: 1, + streamId: stream.streamId, + name: stream.name, + property: property, + value: value, + ); +} + //////////////////////////////////////////////////////////////// // The entire per-account or global state. // diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart index 219529e2da8..18488b8fab8 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -56,6 +56,17 @@ void main() { await store.addSubscription(eg.subscription(stream1)); checkUnified(store); + + await store.handleEvent(eg.channelUpdateEvent(store.streams[stream1.streamId]!, + property: ChannelPropertyName.name, value: 'new stream', + )); + checkUnified(store); + + await store.handleEvent(eg.channelUpdateEvent(store.streams[stream1.streamId]!, + property: ChannelPropertyName.channelPostPolicy, + value: ChannelPostPolicy.administrators, + )); + checkUnified(store); }); });