diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 9fd83c88f4..e095c5360b 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -71,6 +71,13 @@ sealed class Event { case 'peer_remove': return SubscriptionPeerRemoveEvent.fromJson(json); default: return UnexpectedEvent.fromJson(json); } + case 'channel_folder': + switch (json['op'] as String) { + case 'add': return ChannelFolderAddEvent.fromJson(json); + case 'reorder': return ChannelFolderReorderEvent.fromJson(json); + case 'update': return ChannelFolderUpdateEvent.fromJson(json); + default: return UnexpectedEvent.fromJson(json); + } case 'user_status': return UserStatusEvent.fromJson(json); case 'user_topic': return UserTopicEvent.fromJson(json); case 'muted_users': return MutedUsersEvent.fromJson(json); @@ -676,6 +683,8 @@ class ChannelUpdateEvent extends ChannelEvent { return value as int?; case ChannelPropertyName.channelPostPolicy: return ChannelPostPolicy.fromApiValue(value as int); + case ChannelPropertyName.folderId: + return value as int?; case ChannelPropertyName.canAddSubscribersGroup: case ChannelPropertyName.canDeleteAnyMessageGroup: case ChannelPropertyName.canDeleteOwnMessageGroup: @@ -885,6 +894,103 @@ class SubscriptionPeerRemoveEvent extends SubscriptionEvent { Map toJson() => _$SubscriptionPeerRemoveEventToJson(this); } +/// A Zulip event of type `channel_folder`. +/// +/// The corresponding API docs are in several places for +/// different values of `op`; see subclasses. +sealed class ChannelFolderEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'channel_folder'; + + String get op; + + ChannelFolderEvent({required super.id}); +} + +/// A [ChannelFolderEvent] with op `add`: +/// https://zulip.com/api/get-events#channel_folder-add +@JsonSerializable(fieldRename: FieldRename.snake) +class ChannelFolderAddEvent extends ChannelFolderEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'add'; + + final ChannelFolder channelFolder; + + ChannelFolderAddEvent({required super.id, required this.channelFolder}); + + factory ChannelFolderAddEvent.fromJson(Map json) => + _$ChannelFolderAddEventFromJson(json); + + @override + Map toJson() => _$ChannelFolderAddEventToJson(this); +} + +/// A [ChannelFolderEvent] with op `update`: +/// https://zulip.com/api/get-events#channel_folder-update +@JsonSerializable(fieldRename: FieldRename.snake) +class ChannelFolderUpdateEvent extends ChannelFolderEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'update'; + + final int channelFolderId; + final ChannelFolderChange data; + + ChannelFolderUpdateEvent({ + required super.id, + required this.channelFolderId, + required this.data, + }); + + factory ChannelFolderUpdateEvent.fromJson(Map json) => + _$ChannelFolderUpdateEventFromJson(json); + + @override + Map toJson() => _$ChannelFolderUpdateEventToJson(this); +} + +/// Details of a channel-folder change, as in [ChannelFolderUpdateEvent.data]. +@JsonSerializable(fieldRename: FieldRename.snake) +class ChannelFolderChange { + final String? name; + final String? description; + final String? renderedDescription; + final bool? isArchived; + + ChannelFolderChange({ + required this.name, + required this.description, + required this.renderedDescription, + required this.isArchived, + }); + + factory ChannelFolderChange.fromJson(Map json) => + _$ChannelFolderChangeFromJson(json); + + Map toJson() => _$ChannelFolderChangeToJson(this); +} + +/// A [ChannelFolderEvent] with op `update`: +/// https://zulip.com/api/get-events#channel_folder-update +@JsonSerializable(fieldRename: FieldRename.snake) +class ChannelFolderReorderEvent extends ChannelFolderEvent { + @override + @JsonKey(includeToJson: true) + String get op => 'reorder'; + + final List order; + + ChannelFolderReorderEvent({required super.id, required this.order}); + + factory ChannelFolderReorderEvent.fromJson(Map json) => + _$ChannelFolderReorderEventFromJson(json); + + @override + Map toJson() => _$ChannelFolderReorderEventToJson(this); +} + /// A Zulip event of type `user_status`: https://zulip.com/api/get-events#user_status @JsonSerializable(fieldRename: FieldRename.snake, createToJson: false) class UserStatusEvent extends Event { diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 7bf9ef0fcd..dff0d21fa2 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -462,6 +462,7 @@ const _$ChannelPropertyNameEnumMap = { ChannelPropertyName.inviteOnly: 'invite_only', ChannelPropertyName.messageRetentionDays: 'message_retention_days', ChannelPropertyName.channelPostPolicy: 'stream_post_policy', + ChannelPropertyName.folderId: 'folder_id', ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group', ChannelPropertyName.canDeleteAnyMessageGroup: 'can_delete_any_message_group', ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_group', @@ -588,6 +589,77 @@ Map _$SubscriptionPeerRemoveEventToJson( 'user_ids': instance.userIds, }; +ChannelFolderAddEvent _$ChannelFolderAddEventFromJson( + Map json, +) => ChannelFolderAddEvent( + id: (json['id'] as num).toInt(), + channelFolder: ChannelFolder.fromJson( + json['channel_folder'] as Map, + ), +); + +Map _$ChannelFolderAddEventToJson( + ChannelFolderAddEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'channel_folder': instance.channelFolder, +}; + +ChannelFolderUpdateEvent _$ChannelFolderUpdateEventFromJson( + Map json, +) => ChannelFolderUpdateEvent( + id: (json['id'] as num).toInt(), + channelFolderId: (json['channel_folder_id'] as num).toInt(), + data: ChannelFolderChange.fromJson(json['data'] as Map), +); + +Map _$ChannelFolderUpdateEventToJson( + ChannelFolderUpdateEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'channel_folder_id': instance.channelFolderId, + 'data': instance.data, +}; + +ChannelFolderChange _$ChannelFolderChangeFromJson(Map json) => + ChannelFolderChange( + name: json['name'] as String?, + description: json['description'] as String?, + renderedDescription: json['rendered_description'] as String?, + isArchived: json['is_archived'] as bool?, + ); + +Map _$ChannelFolderChangeToJson( + ChannelFolderChange instance, +) => { + 'name': instance.name, + 'description': instance.description, + 'rendered_description': instance.renderedDescription, + 'is_archived': instance.isArchived, +}; + +ChannelFolderReorderEvent _$ChannelFolderReorderEventFromJson( + Map json, +) => ChannelFolderReorderEvent( + id: (json['id'] as num).toInt(), + order: (json['order'] as List) + .map((e) => (e as num).toInt()) + .toList(), +); + +Map _$ChannelFolderReorderEventToJson( + ChannelFolderReorderEvent instance, +) => { + 'id': instance.id, + 'type': instance.type, + 'op': instance.op, + 'order': instance.order, +}; + UserStatusEvent _$UserStatusEventFromJson(Map json) => UserStatusEvent( id: (json['id'] as num).toInt(), diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 8fa6ff0812..74be999eaf 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -52,6 +52,8 @@ class InitialSnapshot { final List subscriptions; + final List? channelFolders; // TODO(server-11) + final UnreadMessagesSnapshot unreadMsgs; final List streams; @@ -168,6 +170,7 @@ class InitialSnapshot { required this.recentPrivateConversations, required this.savedSnippets, required this.subscriptions, + required this.channelFolders, required this.unreadMsgs, required this.streams, required this.userStatuses, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 2cf52446ca..dda2c9e1a0 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -63,6 +63,9 @@ InitialSnapshot _$InitialSnapshotFromJson( subscriptions: (json['subscriptions'] as List) .map((e) => Subscription.fromJson(e as Map)) .toList(), + channelFolders: (json['channel_folders'] as List?) + ?.map((e) => ChannelFolder.fromJson(e as Map)) + .toList(), unreadMsgs: UnreadMessagesSnapshot.fromJson( json['unread_msgs'] as Map, ), @@ -166,6 +169,7 @@ Map _$InitialSnapshotToJson( 'recent_private_conversations': instance.recentPrivateConversations, 'saved_snippets': instance.savedSnippets, 'subscriptions': instance.subscriptions, + 'channel_folders': instance.channelFolders, 'unread_msgs': instance.unreadMsgs, 'streams': instance.streams, 'user_status': instance.userStatuses.map((k, e) => MapEntry(k.toString(), e)), diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index fdfd0c856d..24ca2a8919 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -643,6 +643,8 @@ class ZulipStream { final int dateCreated; int? firstMessageId; + int? folderId; + bool inviteOnly; bool isWebPublic; // present since 2.1, according to /api/changelog bool historyPublicToSubscribers; @@ -673,6 +675,7 @@ class ZulipStream { required this.historyPublicToSubscribers, required this.messageRetentionDays, required this.channelPostPolicy, + required this.folderId, required this.canAddSubscribersGroup, required this.canDeleteAnyMessageGroup, required this.canDeleteOwnMessageGroup, @@ -696,6 +699,7 @@ class ZulipStream { historyPublicToSubscribers: subscription.historyPublicToSubscribers, messageRetentionDays: subscription.messageRetentionDays, channelPostPolicy: subscription.channelPostPolicy, + folderId: subscription.folderId, canAddSubscribersGroup: subscription.canAddSubscribersGroup, canDeleteAnyMessageGroup: subscription.canDeleteAnyMessageGroup, canDeleteOwnMessageGroup: subscription.canDeleteOwnMessageGroup, @@ -732,6 +736,7 @@ enum ChannelPropertyName { messageRetentionDays, @JsonValue('stream_post_policy') channelPostPolicy, + folderId, canAddSubscribersGroup, canDeleteAnyMessageGroup, canDeleteOwnMessageGroup, @@ -816,6 +821,7 @@ class Subscription extends ZulipStream { required super.historyPublicToSubscribers, required super.messageRetentionDays, required super.channelPostPolicy, + required super.folderId, required super.canAddSubscribersGroup, required super.canDeleteAnyMessageGroup, required super.canDeleteOwnMessageGroup, @@ -839,6 +845,38 @@ class Subscription extends ZulipStream { Map toJson() => _$SubscriptionToJson(this); } +/// As in `channel_folders` in the initial snapshot. +/// +/// For docs, search for "channel_folders:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class ChannelFolder { + final int id; + String name; + int? order; // TODO(server-11); added in a later FL than the rest + final int? dateCreated; + final int? creatorId; + String description; + String renderedDescription; + bool isArchived; + + ChannelFolder({ + required this.id, + required this.name, + required this.order, + required this.dateCreated, + required this.creatorId, + required this.description, + required this.renderedDescription, + required this.isArchived, + }); + + factory ChannelFolder.fromJson(Map json) => + _$ChannelFolderFromJson(json); + + Map toJson() => _$ChannelFolderToJson(this); +} + @JsonEnum(fieldRename: FieldRename.snake, valueField: "apiValue") enum UserTopicVisibilityPolicy { none(apiValue: 0), diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index 70316ae1eb..b835c493c5 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -251,6 +251,7 @@ ZulipStream _$ZulipStreamFromJson(Map json) => ZulipStream( _$ChannelPostPolicyEnumMap, json['stream_post_policy'], ), + folderId: (json['folder_id'] as num?)?.toInt(), canAddSubscribersGroup: json['can_add_subscribers_group'] == null ? null : GroupSettingValue.fromJson(json['can_add_subscribers_group']), @@ -278,6 +279,7 @@ Map _$ZulipStreamToJson(ZulipStream instance) => 'rendered_description': instance.renderedDescription, 'date_created': instance.dateCreated, 'first_message_id': instance.firstMessageId, + 'folder_id': instance.folderId, 'invite_only': instance.inviteOnly, 'is_web_public': instance.isWebPublic, 'history_public_to_subscribers': instance.historyPublicToSubscribers, @@ -315,6 +317,7 @@ Subscription _$SubscriptionFromJson(Map json) => Subscription( _$ChannelPostPolicyEnumMap, json['stream_post_policy'], ), + folderId: (json['folder_id'] as num?)?.toInt(), canAddSubscribersGroup: json['can_add_subscribers_group'] == null ? null : GroupSettingValue.fromJson(json['can_add_subscribers_group']), @@ -350,6 +353,7 @@ Map _$SubscriptionToJson(Subscription instance) => 'rendered_description': instance.renderedDescription, 'date_created': instance.dateCreated, 'first_message_id': instance.firstMessageId, + 'folder_id': instance.folderId, 'invite_only': instance.inviteOnly, 'is_web_public': instance.isWebPublic, 'history_public_to_subscribers': instance.historyPublicToSubscribers, @@ -371,6 +375,30 @@ Map _$SubscriptionToJson(Subscription instance) => 'color': instance.color, }; +ChannelFolder _$ChannelFolderFromJson(Map json) => + ChannelFolder( + id: (json['id'] as num).toInt(), + name: json['name'] as String, + order: (json['order'] as num?)?.toInt(), + dateCreated: (json['date_created'] as num?)?.toInt(), + creatorId: (json['creator_id'] as num?)?.toInt(), + description: json['description'] as String, + renderedDescription: json['rendered_description'] as String, + isArchived: json['is_archived'] as bool, + ); + +Map _$ChannelFolderToJson(ChannelFolder instance) => + { + 'id': instance.id, + 'name': instance.name, + 'order': instance.order, + 'date_created': instance.dateCreated, + 'creator_id': instance.creatorId, + 'description': instance.description, + 'rendered_description': instance.renderedDescription, + 'is_archived': instance.isArchived, + }; + StreamConversation _$StreamConversationFromJson(Map json) { $checkKeys( json, @@ -520,6 +548,7 @@ const _$ChannelPropertyNameEnumMap = { ChannelPropertyName.inviteOnly: 'invite_only', ChannelPropertyName.messageRetentionDays: 'message_retention_days', ChannelPropertyName.channelPostPolicy: 'stream_post_policy', + ChannelPropertyName.folderId: 'folder_id', ChannelPropertyName.canAddSubscribersGroup: 'can_add_subscribers_group', ChannelPropertyName.canDeleteAnyMessageGroup: 'can_delete_any_message_group', ChannelPropertyName.canDeleteOwnMessageGroup: 'can_delete_own_message_group', diff --git a/lib/model/channel.dart b/lib/model/channel.dart index ea05dc01d3..10e9596eed 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -42,6 +42,9 @@ mixin ChannelStore on UserStore { /// and [streamsByName]. Map get subscriptions; + /// All the channel folders, including archived ones, indexed by ID. + Map get channelFolders; + static int compareChannelsByName(ZulipStream a, ZulipStream b) { // A user gave feedback wanting zulip-flutter to match web in putting // emoji-prefixed channels first; see #1202. @@ -60,6 +63,21 @@ mixin ChannelStore on UserStore { // ignore: valid_regexps static final _startsWithEmojiRegex = RegExp(r'^\p{Emoji}', unicode: true); + /// A compare function for [ChannelFolder]s, using [ChannelFolder.order]. + /// + /// Channels without [ChannelFolder.order] will come first, + /// sorted alphabetically. + // TODO(server-11) Once [ChannelFolder.order] is required, + // remove alphabetical sorting. + static int compareChannelFolders(ChannelFolder a, ChannelFolder b) { + return switch ((a.order, b.order)) { + (null, null) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + (null, int()) => -1, + (int(), null) => 1, + (int a, int b) => a.compareTo(b), + }; + } + /// The visibility policy that the self-user has for the given topic. /// /// This does not incorporate the user's channel-level policy, @@ -267,6 +285,9 @@ mixin ProxyChannelStore on ChannelStore { @override Map get subscriptions => channelStore.subscriptions; + @override + Map get channelFolders => channelStore.channelFolders; + @override UserTopicVisibilityPolicy topicVisibilityPolicy(int streamId, TopicName topic) => channelStore.topicVisibilityPolicy(streamId, topic); @@ -306,6 +327,9 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { streams.putIfAbsent(stream.streamId, () => stream); } + final channelFolders = Map.fromEntries((initialSnapshot.channelFolders ?? []) + .map((channelFolder) => MapEntry(channelFolder.id, channelFolder))); + final topicVisibility = >{}; for (final item in initialSnapshot.userTopics) { if (_warnInvalidVisibilityPolicy(item.visibilityPolicy)) { @@ -321,6 +345,7 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { streams: streams, streamsByName: streams.map((_, stream) => MapEntry(stream.name, stream)), subscriptions: subscriptions, + channelFolders: channelFolders, topicVisibility: topicVisibility, ); } @@ -330,6 +355,7 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { required this.streams, required this.streamsByName, required this.subscriptions, + required this.channelFolders, required this.topicVisibility, }); @@ -339,6 +365,8 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { final Map streamsByName; @override final Map subscriptions; + @override + final Map channelFolders; @override Map> get debugTopicVisibility => topicVisibility; @@ -418,6 +446,8 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { stream.messageRetentionDays = event.value as int?; case ChannelPropertyName.channelPostPolicy: stream.channelPostPolicy = event.value as ChannelPostPolicy; + case ChannelPropertyName.folderId: + stream.folderId = event.value as int?; case ChannelPropertyName.canAddSubscribersGroup: stream.canAddSubscribersGroup = event.value as GroupSettingValue; case ChannelPropertyName.canDeleteAnyMessageGroup: @@ -496,6 +526,33 @@ class ChannelStoreImpl extends HasUserStore with ChannelStore { } } + void handleChannelFolderEvent(ChannelFolderEvent event) { + switch (event) { + case ChannelFolderAddEvent(): + final newChannelFolder = event.channelFolder; + channelFolders[newChannelFolder.id] = newChannelFolder; + + case ChannelFolderUpdateEvent(): + final change = event.data; + final channelFolder = channelFolders[event.channelFolderId]; + if (channelFolder == null) return; // TODO(log) + + if (change.name != null) channelFolder.name = change.name!; + if (change.description != null) channelFolder.description = change.description!; + if (change.renderedDescription != null) channelFolder.renderedDescription = change.renderedDescription!; + if (change.isArchived != null) channelFolder.isArchived = change.isArchived!; + + case ChannelFolderReorderEvent(): + final order = event.order; + for (int i = 0; i < order.length; i++) { + final id = order[i]; + final channelFolder = channelFolders[id]; + if (channelFolder == null) continue; // TODO(log) + channelFolder.order = i; + } + } + } + void handleUserTopicEvent(UserTopicEvent event) { UserTopicVisibilityPolicy visibilityPolicy = event.visibilityPolicy; if (_warnInvalidVisibilityPolicy(visibilityPolicy)) { diff --git a/lib/model/store.dart b/lib/model/store.dart index 8fbff2e16a..88ca778100 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -829,6 +829,11 @@ class PerAccountStore extends PerAccountStoreBase with _channels.handleSubscriptionEvent(event); notifyListeners(); + case ChannelFolderEvent(): + assert(debugLog("server event: channel_folder/${event.op}")); + _channels.handleChannelFolderEvent(event); + break; + case UserStatusEvent(): assert(debugLog("server event: user_status")); _users.handleUserStatusEvent(event); diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart index 96a223c084..a7db054b55 100644 --- a/test/api/model/model_checks.dart +++ b/test/api/model/model_checks.dart @@ -56,6 +56,17 @@ extension ZulipStreamChecks on Subject { Subject get streamId => has((x) => x.streamId, 'streamId'); } +extension ChannelFolderChecks on Subject { + Subject get id => has((x) => x.id, 'id'); + Subject get name => has((x) => x.name, 'name'); + Subject get order => has((x) => x.order, 'order'); + Subject get dateCreated => has((x) => x.dateCreated, 'dateCreated'); + Subject get creatorId => has((x) => x.creatorId, 'creatorId'); + Subject get description => has((x) => x.description, 'description'); + Subject get renderedDescription => has((x) => x.renderedDescription, 'renderedDescription'); + Subject get isArchived => has((x) => x.isArchived, 'isArchived'); +} + extension TopicNameChecks on Subject { Subject get apiName => has((x) => x.apiName, 'apiName'); Subject get displayName => has((x) => x.displayName, 'displayName'); diff --git a/test/example_data.dart b/test/example_data.dart index cf15221716..e894882225 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -465,6 +465,7 @@ ZulipStream stream({ bool? historyPublicToSubscribers, int? messageRetentionDays, ChannelPostPolicy? channelPostPolicy, + int? folderId, GroupSettingValue? canAddSubscribersGroup, GroupSettingValue? canDeleteAnyMessageGroup, GroupSettingValue? canDeleteOwnMessageGroup, @@ -496,6 +497,7 @@ ZulipStream stream({ historyPublicToSubscribers: historyPublicToSubscribers ?? true, messageRetentionDays: messageRetentionDays, channelPostPolicy: channelPostPolicy ?? ChannelPostPolicy.any, + folderId: folderId, canAddSubscribersGroup: canAddSubscribersGroup ?? GroupSettingValueNamed(nobodyGroup.id), canDeleteAnyMessageGroup: canDeleteAnyMessageGroup ?? GroupSettingValueNamed(nobodyGroup.id), canDeleteOwnMessageGroup: canDeleteOwnMessageGroup ?? GroupSettingValueNamed(nobodyGroup.id), @@ -540,6 +542,7 @@ Subscription subscription( historyPublicToSubscribers: stream.historyPublicToSubscribers, messageRetentionDays: stream.messageRetentionDays, channelPostPolicy: stream.channelPostPolicy, + folderId: stream.folderId, canAddSubscribersGroup: stream.canAddSubscribersGroup, canDeleteAnyMessageGroup: stream.canDeleteAnyMessageGroup, canDeleteOwnMessageGroup: stream.canDeleteOwnMessageGroup, @@ -557,6 +560,49 @@ Subscription subscription( ); } +/// A fresh channel folder ID, +/// from a random but always strictly increasing sequence. +int _nextChannelFolderId() => (_lastChannelFolderId += 1 + Random().nextInt(100)); +int _lastChannelFolderId = 1000; + +ChannelFolder channelFolder({ + int? id, + String? name, + int? order, + int? dateCreated, + int? creatorId, + String? description, + String? renderedDescription, + bool? isArchived, +}) { + final effectiveId = id ?? _nextChannelFolderId(); + final effectiveDescription = description ?? 'An example channel folder.'; + return ChannelFolder( + id: effectiveId, + name: name ?? 'channel folder $effectiveId', + order: order, + dateCreated: dateCreated ?? utcTimestamp(), + creatorId: creatorId ?? selfUser.userId, + description: effectiveDescription, + renderedDescription: renderedDescription ?? '

$effectiveDescription

', + isArchived: isArchived ?? false, + ); +} + +ChannelFolderChange channelFolderChange({ + String? name, + String? description, + String? renderedDescription, + bool? isArchived, +}) { + return ChannelFolderChange( + name: name, + description: description, + renderedDescription: renderedDescription, + isArchived: isArchived, + ); +} + /// The [TopicName] constructor, but shorter. /// /// Useful in test code that mentions a lot of topics in a compact format. @@ -1217,6 +1263,8 @@ ChannelUpdateEvent channelUpdateEvent( assert(value is int?); case ChannelPropertyName.channelPostPolicy: assert(value is ChannelPostPolicy); + case ChannelPropertyName.folderId: + assert(value is int?); case ChannelPropertyName.canAddSubscribersGroup: case ChannelPropertyName.canDeleteAnyMessageGroup: case ChannelPropertyName.canDeleteOwnMessageGroup: @@ -1276,6 +1324,7 @@ InitialSnapshot initialSnapshot({ List? recentPrivateConversations, List? savedSnippets, List? subscriptions, + List? channelFolders, UnreadMessagesSnapshot? unreadMsgs, List? streams, Map? userStatuses, @@ -1332,6 +1381,7 @@ InitialSnapshot initialSnapshot({ recentPrivateConversations: recentPrivateConversations ?? [], savedSnippets: savedSnippets ?? [], subscriptions: subscriptions ?? [], // TODO add subscriptions to default + channelFolders: channelFolders ?? [], unreadMsgs: unreadMsgs ?? _unreadMsgs(), streams: streams ?? [], // TODO add streams to default userStatuses: userStatuses ?? {}, diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart index e73965dac2..6c2de3e83c 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -1,4 +1,5 @@ import 'package:checks/checks.dart'; +import 'package:collection/collection.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; @@ -91,6 +92,21 @@ void main() { }); }); + group('channelFolderComparator', () { + final folder1 = eg.channelFolder(id: 1, order: null, name: 'M'); + final folder2 = eg.channelFolder(id: 2, order: null, name: 'n'); + final folder3 = eg.channelFolder(id: 3, order: 2, name: 'a'); + final folder4 = eg.channelFolder(id: 4, order: 0, name: 'b'); + final folder5 = eg.channelFolder(id: 5, order: 1, name: 'c'); + + final store = eg.store(initialSnapshot: eg.initialSnapshot( + channelFolders: [folder1, folder2, folder3, folder4, folder5])); + + final sorted = store.channelFolders.values.toList() + .sorted(ChannelStore.compareChannelFolders); + check(sorted).deepEquals([folder1, folder2, folder4, folder5, folder3]); + }); + group('SubscriptionEvent', () { final stream = eg.stream(); @@ -123,6 +139,88 @@ void main() { }); }); + group('ChannelFolderEvent', () { + group('add', () { + test('smoke', () async { + final folder1 = eg.channelFolder(id: 1); + final folder2 = eg.channelFolder(id: 2); + final store = eg.store(initialSnapshot: eg.initialSnapshot( + channelFolders: [folder1])); + + await store.handleEvent(ChannelFolderAddEvent( + id: 1, channelFolder: folder2)); + check(store.channelFolders).deepEquals({1: folder1, 2: folder2}); + }); + }); + + group('update', () { + void doTest(ChannelFolderChange change) { + final ChannelFolderChange( + :name, :description, :renderedDescription, :isArchived) = change; + final testDescription = [ + if (name != null) 'name: $name', + if (description != null) 'description: $description', + if (renderedDescription != null) 'renderedDescription: $renderedDescription', + if (isArchived != null) 'isArchived: $isArchived', + ].join(', '); + test(testDescription, () async { + final channelFolder = eg.channelFolder(); + assert(name == null || name != channelFolder.name); + assert(description == null || description != channelFolder.description); + assert(renderedDescription == null || renderedDescription != channelFolder.renderedDescription); + assert(isArchived == null || isArchived != channelFolder.isArchived); + + final store = eg.store(initialSnapshot: eg.initialSnapshot( + channelFolders: [channelFolder])); + await store.handleEvent(ChannelFolderUpdateEvent(id: 1, + channelFolderId: channelFolder.id, data: change)); + check(store.channelFolders.values.single) + ..name.equals(name ?? channelFolder.name) + ..description.equals(description ?? channelFolder.description) + ..renderedDescription.equals(renderedDescription ?? channelFolder.renderedDescription) + ..isArchived.equals(isArchived ?? channelFolder.isArchived); + }); + } + + doTest(eg.channelFolderChange(name: 'new name')); + doTest(eg.channelFolderChange(description: 'new description')); + doTest(eg.channelFolderChange(renderedDescription: '

new description

')); + doTest(eg.channelFolderChange(isArchived: true)); + + doTest(eg.channelFolderChange( + name: 'new name', + description: 'new description', + renderedDescription: '

new description

', + isArchived: true, + )); + }); + + group('reorder', () { + List foldersFromStoreInOrder(PerAccountStore store) { + return store.channelFolders.values.toList() + ..sort((a, b) => a.order!.compareTo(b.order!)); + } + + test('smoke', () async { + final folderA = eg.channelFolder(order: 0); + final folderB = eg.channelFolder(order: 1); + final folderC = eg.channelFolder(order: 2); + + final store = eg.store(initialSnapshot: eg.initialSnapshot( + channelFolders: [folderA, folderB, folderC])); + check(foldersFromStoreInOrder(store)).deepEquals([folderA, folderB, folderC]); + + await store.handleEvent(ChannelFolderReorderEvent(id: 1, + order: [folderA.id, folderC.id, folderB.id])); + check(foldersFromStoreInOrder(store)).deepEquals([folderA, folderC, folderB]); + + await store.handleEvent(ChannelFolderReorderEvent(id: 1, + order: [folderC.id, folderB.id, folderA.id])); + check(foldersFromStoreInOrder(store)).deepEquals([folderC, folderB, folderA]); + }); + }); + }); + group('topic visibility', () { final stream1 = eg.stream(); final stream2 = eg.stream(); diff --git a/test/model/test_store.dart b/test/model/test_store.dart index 303f59974b..5bf78f3f4a 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -350,6 +350,10 @@ extension PerAccountStoreTestExtension on PerAccountStore { await handleEvent(SubscriptionRemoveEvent(id: 1, streamIds: channelIds)); } + Future addChannelFolder(ChannelFolder channelFolder) async { + await handleEvent(ChannelFolderAddEvent(id: 1, channelFolder: channelFolder)); + } + Future setUserTopic(ZulipStream stream, String topic, UserTopicVisibilityPolicy visibilityPolicy) async { await handleEvent(eg.userTopicEvent(stream.streamId, topic, visibilityPolicy)); }